Self-contained HTML-Kanban als operative Single Source of Truth für alle laufenden Projekte. Visualisiert das Ringsystem. Stack: Vanilla HTML/CSS/JS, localStorage, kein Build, kein Backend Server: ~/dev/kanban/server.sh (Python http.server auf Port 8765) Spec: ~/dev/kanban/SPEC.md 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2332 lines
130 KiB
HTML
2332 lines
130 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Robin — Projects</title>
|
||
<style>
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
:root {
|
||
--bg:#0d0d0f; --surface:#16161a; --surface2:#1e1e24; --surface3:#26262e;
|
||
--border:#2a2a35; --border2:#353545;
|
||
--text:#e8e8f0; --text-muted:#6b6b80; --text-dim:#4a4a5a;
|
||
--accent:#7c6af7; --accent-soft:#2d2550;
|
||
--green:#4ade80; --green-soft:#0f2e1a;
|
||
--amber:#fbbf24; --amber-soft:#2d2000;
|
||
--red:#f87171; --red-soft:#2d0f0f;
|
||
--blue:#60a5fa; --blue-soft:#0f1e2d;
|
||
--teal:#2dd4bf; --teal-soft:#0a2420;
|
||
--purple:#c084fc; --purple-soft:#1e0f2d;
|
||
--orange:#fb923c; --orange-soft:#2d1500;
|
||
--sidebar-w:210px; --col-w:240px;
|
||
}
|
||
|
||
body { background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,'Inter','Segoe UI',sans-serif; font-size:14px; height:100vh; display:flex; overflow:hidden; }
|
||
|
||
/* ── SIDEBAR ── */
|
||
.sidebar { width:var(--sidebar-w); min-width:var(--sidebar-w); background:var(--surface); border-right:1px solid var(--border); display:flex; flex-direction:column; overflow-y:auto; padding-bottom:20px; }
|
||
.s-head { padding:16px 14px 10px; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--text-muted); position:sticky; top:0; background:var(--surface); border-bottom:1px solid var(--border); margin-bottom:6px; }
|
||
.s-group { padding:10px 14px 3px; font-size:9.5px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--text-dim); }
|
||
.nav-item { display:flex; align-items:center; gap:8px; padding:6px 14px; cursor:pointer; border-left:2px solid transparent; user-select:none; transition:background .1s; }
|
||
.nav-item:hover { background:var(--surface2); }
|
||
.nav-item.active { background:var(--accent-soft); border-left-color:var(--accent); }
|
||
.nav-item.active .nav-name { color:var(--accent); }
|
||
.nav-dot { width:7px; height:7px; border-radius:50%; flex-shrink:0; }
|
||
.nav-name { flex:1; font-size:13px; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||
.nav-badge { font-size:10px; font-weight:700; color:var(--text-dim); background:var(--surface3); padding:1px 6px; border-radius:10px; min-width:18px; text-align:center; }
|
||
.nav-badge.wip { background:var(--blue-soft); color:var(--blue); }
|
||
.nav-badge.blk { background:var(--red-soft); color:var(--red); }
|
||
.s-reset { margin:6px 14px 0; padding:5px 10px; background:var(--surface3); border:1px solid var(--border); border-radius:5px; font-size:10px; color:var(--text-dim); cursor:pointer; text-align:center; }
|
||
.s-reset:hover { color:var(--red); border-color:var(--red); }
|
||
.s-actions { display:flex; gap:6px; padding:10px 14px 0; }
|
||
.s-add-btn { flex:1; text-align:center; padding:5px 0; background:var(--surface2); border:1px solid var(--border2); border-radius:5px; font-size:10.5px; color:var(--text-dim); cursor:pointer; }
|
||
.s-add-btn:hover { color:var(--accent); border-color:var(--accent); }
|
||
.col-add-input { width:100%; background:transparent; border:none; border-top:1px solid var(--border); color:var(--text-muted); font-size:12px; padding:6px 10px; outline:none; border-radius:0 0 6px 6px; flex-shrink:0; }
|
||
.col-add-input:focus { background:var(--surface3); color:var(--text); }
|
||
.col-add-input::placeholder { color:var(--text-dim); }
|
||
.nav-del { font-size:11px; color:var(--text-dim); cursor:pointer; opacity:0; padding:0 2px; }
|
||
.nav-item:hover .nav-del { opacity:1; }
|
||
.nav-del:hover { color:var(--red); }
|
||
.s-group-section { border-radius:6px; transition:background .1s; }
|
||
.s-group-section.drag-over { background:var(--accent-soft); }
|
||
.s-group-section.drag-over .s-group { color:var(--accent); }
|
||
.s-group-section.group-drop-target { border-top:2px solid var(--accent); }
|
||
.s-group-section.meta { background:linear-gradient(135deg, rgba(192,132,252,.10), rgba(124,106,247,.05)); border:1px solid rgba(192,132,252,.25); margin:0 6px 8px; box-shadow:0 0 0 1px rgba(192,132,252,.10), 0 4px 12px rgba(124,106,247,.10); }
|
||
.s-group-section.meta .s-group { color:var(--purple); letter-spacing:.12em; }
|
||
.s-group-section.meta .s-group::before { content:'◉ '; opacity:.7; }
|
||
.s-group-section.meta .nav-item.active { background:rgba(192,132,252,.18); border-left-color:var(--purple); }
|
||
.s-group-section.meta .nav-item.active .nav-name { color:var(--purple); }
|
||
.s-group { cursor:grab; user-select:none; }
|
||
.s-group.group-dragging { opacity:.35; }
|
||
.nav-item.board-dragging { opacity:.35; }
|
||
.nav-item.drop-target { border-bottom:2px solid var(--accent); border-radius:0; }
|
||
.nav-ring { font-size:9px; font-weight:700; padding:1px 5px; border-radius:8px; background:var(--surface3); color:var(--text-dim); cursor:pointer; flex-shrink:0; user-select:none; transition:all .1s; }
|
||
.nav-ring:hover { transform:scale(1.05); }
|
||
.nav-ring.r-0 { background:var(--purple-soft); color:var(--purple); }
|
||
.nav-ring.r-1p { background:var(--red-soft); color:var(--red); }
|
||
.nav-ring.r-1w { background:var(--amber-soft); color:var(--amber); }
|
||
.nav-ring.r-2 { background:var(--teal-soft); color:var(--teal); }
|
||
.nav-ring.r-3 { background:var(--green-soft); color:var(--green); }
|
||
.nav-ring.r-q { background:var(--surface3); color:var(--text-dim); }
|
||
.idea-card.dragging { opacity:.35; }
|
||
.ideas-drop-zones { border-top:1px solid var(--border); padding:8px 10px; display:none; flex-direction:column; gap:4px; flex-shrink:0; background:var(--surface2); }
|
||
.ideas-drop-zones.active { display:flex; }
|
||
.ideas-drop-zone { padding:8px; border:1px dashed var(--border2); border-radius:5px; text-align:center; font-size:10.5px; font-weight:700; letter-spacing:.04em; text-transform:uppercase; color:var(--text-dim); transition:all .1s; }
|
||
.ideas-drop-zone.dz-projekt { border-color:var(--accent); color:var(--accent); }
|
||
.ideas-drop-zone.dz-erkenntnis { border-color:var(--teal); color:var(--teal); }
|
||
.ideas-drop-zone.dz-spaeter { border-color:var(--amber); color:var(--amber); }
|
||
.ideas-drop-zone.drag-over { background:var(--accent-soft); transform:scale(1.02); }
|
||
|
||
/* ── MAIN ── */
|
||
.main { flex:1; display:flex; flex-direction:column; overflow:hidden; }
|
||
.board-header { padding:13px 22px 10px; background:var(--surface); border-bottom:1px solid var(--border); flex-shrink:0; }
|
||
.board-title { font-size:19px; font-weight:700; margin-bottom:3px; outline:none; border-radius:3px; padding:0 3px; margin-left:-3px; }
|
||
.board-title:focus { background:var(--surface2); box-shadow:0 0 0 1px var(--accent-soft); }
|
||
.board-title:hover { background:var(--surface2); cursor:text; }
|
||
.board-goal { font-size:12.5px; color:var(--text-muted); line-height:1.4; max-width:700px; outline:none; border-radius:3px; padding:0 3px; margin-left:-3px; }
|
||
.board-goal:focus { background:var(--surface2); box-shadow:0 0 0 1px var(--accent-soft); color:var(--text); }
|
||
.board-goal:hover { background:var(--surface2); cursor:text; }
|
||
.board-pills { display:flex; gap:7px; margin-top:8px; flex-wrap:wrap; align-items:center; }
|
||
|
||
.focus-row { display:flex; align-items:center; gap:8px; margin-top:8px; }
|
||
.focus-star { color:var(--amber); font-size:13px; flex-shrink:0; }
|
||
.focus-label { font-size:10px; font-weight:700; letter-spacing:.07em; text-transform:uppercase; color:var(--text-dim); flex-shrink:0; }
|
||
.focus-text { font-size:12.5px; color:var(--amber); font-weight:500; outline:none; background:transparent; border:none; border-bottom:1px dashed var(--border2); padding:1px 4px; min-width:120px; max-width:500px; flex:1; }
|
||
.focus-text:focus { border-bottom-color:var(--amber); color:var(--text); }
|
||
.focus-text:empty:before { content:attr(data-placeholder); color:var(--text-dim); font-style:italic; }
|
||
.pill { font-size:11px; font-weight:600; padding:2px 10px; border-radius:20px; }
|
||
.pill-edit { font-size:11px; font-weight:600; padding:2px 10px; border-radius:20px; background:var(--surface3); color:var(--text-muted); display:inline-flex; align-items:center; gap:4px; }
|
||
.pill-edit-val { outline:none; background:transparent; border:none; color:var(--text); font-weight:700; min-width:14px; text-align:center; padding:0 2px; border-radius:3px; cursor:text; }
|
||
.pill-edit-val:focus { background:var(--surface2); box-shadow:0 0 0 1px var(--accent); }
|
||
.pill-edit-val:hover { background:var(--surface2); }
|
||
|
||
.view-tabs { display:flex; background:var(--surface); border-bottom:1px solid var(--border); padding:0 22px; flex-shrink:0; }
|
||
.view-tab { padding:8px 16px; font-size:12px; font-weight:600; color:var(--text-muted); cursor:pointer; border-bottom:2px solid transparent; user-select:none; transition:color .1s,border-color .1s; }
|
||
.view-tab:hover { color:var(--text); }
|
||
.view-tab.active { color:var(--accent); border-bottom-color:var(--accent); }
|
||
|
||
/* ── BOARD SCROLL AREA ── */
|
||
#view-board { flex:1; overflow-x:auto; overflow-y:hidden; padding:16px 22px; display:flex; flex-direction:column; gap:12px; }
|
||
|
||
/* ── EXPEDITE SWIMLANE ── */
|
||
.exp-header { font-size:9.5px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--red); padding:6px 2px 4px; flex-shrink:0; opacity:.8; }
|
||
.exp-swimlane { display:flex; gap:10px; flex-shrink:0; }
|
||
.exp-zone { flex:1; min-width:220px; background:rgba(248,113,113,.05); border:1px solid rgba(248,113,113,.18); border-radius:5px; padding:5px; min-height:44px; display:flex; flex-direction:column; gap:4px; flex-shrink:0; }
|
||
.exp-zone.drag-over { background:rgba(248,113,113,.15); outline:2px dashed var(--red); outline-offset:-2px; }
|
||
.exp-zone-empty { display:flex; align-items:center; justify-content:center; flex:1; color:rgba(248,113,113,.25); font-size:10px; min-height:28px; }
|
||
.col-sep { height:1px; background:var(--border); margin:5px 0; flex-shrink:0; }
|
||
|
||
/* ── BOARD ── */
|
||
.board { display:flex; gap:10px; flex:1; overflow-y:hidden; }
|
||
.column { flex:1; min-width:220px; display:flex; flex-direction:column; }
|
||
|
||
.col-head { display:flex; align-items:center; gap:6px; padding:7px 10px; background:var(--surface2); border:1px solid var(--border); border-bottom:none; border-radius:6px 6px 0 0; }
|
||
.col-ready .col-head { border-top:2px solid var(--amber); }
|
||
.col-wip .col-head { border-top:2px solid var(--blue); }
|
||
.col-done .col-head { border-top:2px solid var(--green); }
|
||
|
||
.col-title { font-size:10px; font-weight:700; letter-spacing:.07em; text-transform:uppercase; color:var(--text-muted); flex:1; }
|
||
.col-limit { font-size:10px; font-weight:700; padding:1px 6px; border-radius:8px; }
|
||
.lim-ok { background:var(--surface3); color:var(--text-dim); }
|
||
.lim-warn { background:var(--amber-soft); color:var(--amber); }
|
||
.lim-over { background:var(--red-soft); color:var(--red); }
|
||
|
||
.col-body { flex:1; background:var(--surface2); border:1px solid var(--border); border-top:2px solid var(--border2); border-radius:0 0 6px 6px; padding:6px; overflow-y:auto; min-height:200px; display:flex; flex-direction:column; gap:5px; }
|
||
.col-ready .col-body { border-top-color:var(--amber); }
|
||
.col-wip .col-body { border-top-color:var(--blue); }
|
||
.col-done .col-body { border-top-color:var(--green); }
|
||
|
||
.col-body.drag-over { background:var(--surface3); outline:2px dashed var(--accent); outline-offset:-3px; }
|
||
|
||
/* ── CARDS ── */
|
||
.card { position:relative; background:var(--surface3); border:1px solid var(--border); border-radius:5px; padding:8px 10px; transition:border-color .12s,box-shadow .12s; cursor:grab; }
|
||
.card:hover { border-color:var(--border2); box-shadow:0 2px 10px rgba(0,0,0,.35); }
|
||
.card.dragging { opacity:.4; cursor:grabbing; }
|
||
.card.cos-expedite { border-left:3px solid var(--red); }
|
||
.card.cos-fixed { border-left:3px solid var(--amber); }
|
||
.card.blocked { border-color:var(--red); background:rgba(248,113,113,.06); }
|
||
.flag-btn { background:none; border:none; cursor:pointer; font-size:11px; color:var(--text-dim); padding:0 1px; line-height:1; transition:color .1s; flex-shrink:0; }
|
||
.flag-btn:hover { color:var(--red); }
|
||
.flag-btn.on { color:var(--red); }
|
||
.card-del-btn { background:none; border:none; cursor:pointer; font-size:13px; color:var(--text-dim); padding:0 1px; line-height:1; opacity:0; transition:opacity .1s,color .1s; flex-shrink:0; }
|
||
.card:hover .card-del-btn { opacity:1; }
|
||
.card-del-btn:hover { color:var(--red); }
|
||
.card-promote-btn { background:none; border:none; cursor:pointer; font-size:11px; color:var(--text-dim); padding:0 1px; line-height:1; opacity:0; transition:opacity .1s,color .1s; flex-shrink:0; }
|
||
.card:hover .card-promote-btn { opacity:1; }
|
||
.card-promote-btn:hover { color:var(--accent); }
|
||
.promote-hint { font-size:10.5px; color:var(--amber); padding:6px 10px; background:var(--amber-soft); border-radius:5px; margin-bottom:10px; line-height:1.4; }
|
||
.card-title { font-size:13px; font-weight:500; color:var(--text); line-height:1.4; margin-bottom:4px; }
|
||
.card-meta { display:flex; align-items:center; gap:5px; flex-wrap:wrap; }
|
||
.tag { font-size:9px; font-weight:700; letter-spacing:.04em; padding:2px 6px; border-radius:3px; text-transform:uppercase; }
|
||
.tag-bl { background:var(--red-soft); color:var(--red); }
|
||
.tag-sec { background:var(--amber-soft); color:var(--amber); }
|
||
.tag-ux { background:var(--purple-soft); color:var(--purple); }
|
||
.age-dot { width:6px; height:6px; border-radius:50%; flex-shrink:0; }
|
||
.age-fresh { background:var(--green); }
|
||
.age-ok { background:var(--teal); }
|
||
.age-warn { background:var(--amber); }
|
||
.age-old { background:var(--red); }
|
||
.age-lbl { font-size:9.5px; color:var(--text-dim); }
|
||
.done-note { font-size:10px; color:var(--text-dim); font-style:italic; margin-top:4px; line-height:1.3; }
|
||
.col-done .card { opacity:.5; }
|
||
.col-done .card:hover { opacity:.85; }
|
||
.empty-col { flex:1; display:flex; align-items:center; justify-content:center; color:var(--text-dim); font-size:11px; min-height:50px; letter-spacing:.04em; }
|
||
|
||
/* ── ANALYTICS ── */
|
||
#view-analytics { flex:1; overflow-y:auto; padding:18px 22px; display:none; }
|
||
.kpi-grid { display:grid; grid-template-columns:repeat(5,1fr); gap:10px; margin-bottom:16px; }
|
||
.kpi { background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:13px; }
|
||
.kpi-lbl { font-size:9.5px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--text-muted); margin-bottom:5px; }
|
||
.kpi-val { font-size:26px; font-weight:700; line-height:1; }
|
||
.kpi-unit { font-size:11px; color:var(--text-muted); margin-left:2px; }
|
||
.kpi-sub { font-size:10px; color:var(--text-dim); margin-top:4px; }
|
||
.kv-r { color:var(--red); } .kv-a { color:var(--amber); }
|
||
.kv-g { color:var(--green); } .kv-b { color:var(--blue); } .kv-t { color:var(--teal); }
|
||
|
||
.a-row { display:grid; gap:12px; margin-bottom:12px; }
|
||
.a-row.two { grid-template-columns:1fr 1fr; }
|
||
.a-panel { background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:15px; }
|
||
.a-title { font-size:9.5px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--text-muted); margin-bottom:13px; }
|
||
|
||
.flow-rows { display:flex; flex-direction:column; gap:8px; }
|
||
.flow-row { display:flex; align-items:center; gap:8px; }
|
||
.flow-lbl { width:80px; font-size:11px; color:var(--text-muted); text-align:right; flex-shrink:0; }
|
||
.flow-track { flex:1; background:var(--surface2); border-radius:3px; height:20px; position:relative; overflow:hidden; }
|
||
.flow-fill { height:100%; border-radius:3px; }
|
||
.flow-n { position:absolute; right:7px; top:50%; transform:translateY(-50%); font-size:10px; font-weight:700; color:var(--text-muted); }
|
||
|
||
.mc-ctrl { display:flex; align-items:center; gap:10px; margin-bottom:13px; flex-wrap:wrap; }
|
||
.mc-lbl { font-size:11px; color:var(--text-muted); }
|
||
.mc-inp { background:var(--surface2); border:1px solid var(--border2); color:var(--text); font-size:12px; font-weight:600; padding:4px 8px; border-radius:4px; width:55px; text-align:center; }
|
||
.mc-inp:focus { outline:none; border-color:var(--accent); }
|
||
.mc-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:8px; margin-bottom:10px; }
|
||
.mc-cell { background:var(--surface2); border-radius:6px; padding:10px 6px; text-align:center; }
|
||
.mc-pct { font-size:9.5px; font-weight:700; color:var(--text-dim); margin-bottom:5px; }
|
||
.mc-weeks { font-size:20px; font-weight:700; }
|
||
.mc-date { font-size:9.5px; color:var(--text-muted); margin-top:3px; }
|
||
.mc-range { height:8px; background:var(--surface2); border-radius:4px; overflow:hidden; margin-bottom:8px; }
|
||
.mc-range-fill { height:100%; background:linear-gradient(90deg,var(--green),var(--teal),var(--amber),var(--red)); }
|
||
.mc-note { font-size:10.5px; color:var(--text-dim); line-height:1.4; }
|
||
|
||
.blk-item { display:flex; align-items:flex-start; gap:8px; padding:8px 0; border-bottom:1px solid var(--border); }
|
||
.blk-item:last-child { border-bottom:none; }
|
||
.blk-dot { width:8px; height:8px; border-radius:50%; background:var(--red); margin-top:4px; flex-shrink:0; }
|
||
.blk-title { font-size:12px; color:var(--text); }
|
||
.blk-board { font-size:10px; color:var(--text-muted); margin-top:2px; }
|
||
|
||
.sle-item { display:flex; align-items:center; gap:10px; padding:9px 0; border-bottom:1px solid var(--border); }
|
||
.sle-item:last-child { border-bottom:none; }
|
||
.sle-text { flex:1; font-size:12px; line-height:1.4; }
|
||
.sle-badge { font-size:10px; font-weight:700; padding:2px 8px; border-radius:10px; }
|
||
|
||
/* ── OVERVIEW ── */
|
||
#view-overview { flex:1; overflow-y:auto; padding:18px 22px; display:none; }
|
||
.ov-hero { display:flex; gap:20px; align-items:center; padding:20px 22px; margin-bottom:18px; background:linear-gradient(135deg, var(--surface) 0%, var(--surface2) 100%); border:1px solid var(--border); border-radius:14px; }
|
||
.ov-hero-icon { width:96px; height:96px; border-radius:22px; flex-shrink:0; box-shadow:0 8px 24px rgba(0,0,0,.45); object-fit:cover; }
|
||
.ov-hero-icon-fb { width:96px; height:96px; border-radius:22px; flex-shrink:0; display:flex; align-items:center; justify-content:center; font-size:48px; box-shadow:0 8px 24px rgba(0,0,0,.45); }
|
||
.ov-hero-body { flex:1; min-width:0; }
|
||
.ov-hero-name { font-size:22px; font-weight:700; color:var(--text); margin-bottom:3px; line-height:1.2; }
|
||
.ov-hero-tag { font-size:13px; color:var(--accent); font-style:italic; margin-bottom:8px; }
|
||
.ov-hero-desc { font-size:12.5px; color:var(--text-muted); line-height:1.55; margin-bottom:10px; max-width:680px; }
|
||
.ov-hero-badges { display:flex; gap:6px; flex-wrap:wrap; align-items:center; }
|
||
.ov-hero-badge { font-size:10px; font-weight:700; letter-spacing:.05em; text-transform:uppercase; padding:3px 9px; border-radius:12px; background:var(--surface3); color:var(--text-muted); }
|
||
.ov-launch { display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); gap:10px; margin-bottom:18px; }
|
||
.ov-launch-card { background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:12px 14px; display:flex; align-items:center; gap:10px; text-decoration:none; color:var(--text); cursor:pointer; transition:all .12s; }
|
||
.ov-launch-card:hover { border-color:var(--accent); background:var(--accent-soft); transform:translateY(-1px); }
|
||
.ov-launch-card .ov-launch-lbl, .ov-launch-card .ov-launch-sub, .ov-launch-card .ov-launch-icon { cursor:pointer; }
|
||
.ov-launch-card [contenteditable="true"] { cursor:text; background:var(--surface3); box-shadow:0 0 0 1px var(--accent); outline:none; border-radius:3px; }
|
||
.ov-launch-icon { font-size:22px; flex-shrink:0; }
|
||
.ov-launch-meta { flex:1; min-width:0; }
|
||
.ov-launch-lbl { font-size:12px; font-weight:700; color:var(--text); margin-bottom:1px; }
|
||
.ov-launch-sub { font-size:10.5px; color:var(--text-muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||
.ov-row { display:grid; gap:12px; margin-bottom:12px; }
|
||
.ov-row.two { grid-template-columns:1fr 1fr; }
|
||
.ov-row.full { grid-template-columns:1fr; }
|
||
.ov-panel { background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:16px 18px; }
|
||
.ov-title { font-size:9.5px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--text-muted); margin-bottom:12px; }
|
||
.ov-summary { font-size:13px; line-height:1.55; color:var(--text); white-space:pre-line; }
|
||
.ov-stack { display:flex; flex-wrap:wrap; gap:6px; }
|
||
.ov-pill { font-size:11px; font-weight:600; padding:3px 9px; border-radius:20px; background:var(--surface3); color:var(--text); }
|
||
.ov-info-row { display:flex; align-items:center; gap:10px; padding:8px 0; border-bottom:1px solid var(--border); font-size:12.5px; }
|
||
.ov-info-row:last-child { border-bottom:none; }
|
||
.ov-info-lbl { width:160px; font-size:11px; font-weight:600; color:var(--text-muted); flex-shrink:0; }
|
||
.ov-info-val { flex:1; font-family:'SF Mono','Monaco','Menlo',monospace; font-size:11.5px; color:var(--text); word-break:break-all; line-height:1.5; }
|
||
.ov-info-val.masked { letter-spacing:.15em; color:var(--text-dim); user-select:none; }
|
||
.ov-icon-btn { background:none; border:1px solid var(--border); border-radius:5px; cursor:pointer; padding:4px 8px; font-size:11px; color:var(--text-muted); flex-shrink:0; transition:all .12s; }
|
||
.ov-icon-btn:hover { color:var(--accent); border-color:var(--accent); }
|
||
.ov-icon-btn.copied { color:var(--green); border-color:var(--green); }
|
||
.ov-cmd { background:var(--surface3); border:1px solid var(--border); border-radius:5px; padding:8px 11px; font-family:'SF Mono','Monaco','Menlo',monospace; font-size:11.5px; color:var(--teal); display:flex; align-items:center; gap:10px; }
|
||
.ov-cmd-text { flex:1; word-break:break-all; }
|
||
.ov-empty { font-size:12px; color:var(--text-dim); padding:8px 0; font-style:italic; }
|
||
.ov-edit { outline:none; border-radius:3px; padding:1px 4px; transition:background .1s; cursor:text; }
|
||
.ov-edit:hover { background:var(--surface2); }
|
||
.ov-edit:focus { background:var(--surface3); box-shadow:0 0 0 1px var(--accent-soft); color:var(--text); }
|
||
.ov-edit:empty:before { content:attr(data-placeholder); color:var(--text-dim); font-style:italic; }
|
||
.ov-row-del { background:none; border:none; cursor:pointer; font-size:13px; color:var(--text-dim); padding:0 4px; opacity:0; transition:opacity .1s,color .1s; flex-shrink:0; }
|
||
.ov-info-row:hover .ov-row-del, .ov-cmd:hover .ov-row-del { opacity:1; }
|
||
.ov-row-del:hover { color:var(--red); }
|
||
.ov-add-row { margin-top:8px; padding:6px 10px; background:transparent; border:1px dashed var(--border2); border-radius:5px; font-size:11px; color:var(--text-dim); cursor:pointer; text-align:center; transition:all .1s; }
|
||
.ov-add-row:hover { border-color:var(--accent); color:var(--accent); }
|
||
.ov-pill-wrap { display:inline-flex; align-items:center; gap:4px; }
|
||
.ov-pill-del { background:none; border:none; cursor:pointer; font-size:10px; color:var(--text-dim); padding:0; line-height:1; opacity:0; transition:opacity .1s; }
|
||
.ov-pill-wrap:hover .ov-pill-del { opacity:1; }
|
||
.ov-pill-del:hover { color:var(--red); }
|
||
.ov-pill-input { background:var(--surface3); border:1px dashed var(--border2); color:var(--text); font-size:11px; padding:3px 9px; border-radius:20px; outline:none; width:90px; }
|
||
.ov-pill-input:focus { border-style:solid; border-color:var(--accent); }
|
||
.card.aging { box-shadow:0 0 0 1px var(--red), 0 0 12px rgba(248,113,113,.18); }
|
||
.card .age-warn-lbl { font-size:9px; color:var(--red); font-weight:700; letter-spacing:.06em; }
|
||
|
||
/* ── MD-VIEWER MODAL ── */
|
||
.md-overlay { position:fixed; inset:0; background:rgba(0,0,0,.65); display:none; align-items:flex-start; justify-content:center; z-index:200; padding:40px 20px; overflow-y:auto; }
|
||
.md-overlay.active { display:flex; }
|
||
.md-box { background:var(--surface); border:1px solid var(--border2); border-radius:10px; padding:32px 36px; max-width:840px; width:100%; max-height:85vh; overflow-y:auto; position:relative; font-size:13.5px; line-height:1.65; color:var(--text); box-shadow:0 16px 48px rgba(0,0,0,.6); }
|
||
.md-box .md-header { display:flex; align-items:center; justify-content:space-between; padding-bottom:14px; margin-bottom:18px; border-bottom:1px solid var(--border); }
|
||
.md-box .md-path { font-family:monospace; font-size:11px; color:var(--text-dim); }
|
||
.md-box .md-actions { display:flex; gap:6px; }
|
||
.md-close { background:var(--surface3); border:none; color:var(--text-muted); font-size:14px; cursor:pointer; padding:5px 10px; border-radius:5px; }
|
||
.md-close:hover { color:var(--red); background:var(--surface2); }
|
||
.md-content h1 { font-size:24px; font-weight:700; margin:24px 0 12px; }
|
||
.md-content h2 { font-size:18px; font-weight:700; margin:20px 0 10px; color:var(--accent); padding-bottom:5px; border-bottom:1px solid var(--border); }
|
||
.md-content h3 { font-size:14px; font-weight:700; margin:16px 0 8px; color:var(--text-muted); letter-spacing:.04em; text-transform:uppercase; }
|
||
.md-content p { margin-bottom:10px; }
|
||
.md-content ul, .md-content ol { padding-left:22px; margin-bottom:12px; }
|
||
.md-content li { margin-bottom:4px; }
|
||
.md-content code { background:var(--surface3); padding:1px 6px; border-radius:3px; font-family:'SF Mono',Monaco,Menlo,monospace; font-size:12px; color:var(--teal); }
|
||
.md-content pre { background:var(--surface3); padding:14px; border-radius:6px; overflow-x:auto; margin-bottom:12px; border:1px solid var(--border); }
|
||
.md-content pre code { background:none; padding:0; color:var(--text); font-size:11.5px; }
|
||
.md-content blockquote { border-left:3px solid var(--accent); padding-left:14px; color:var(--text-muted); margin:10px 0; font-style:italic; }
|
||
.md-content table { border-collapse:collapse; margin:12px 0; font-size:12.5px; width:100%; }
|
||
.md-content th, .md-content td { border:1px solid var(--border); padding:7px 11px; text-align:left; }
|
||
.md-content th { background:var(--surface2); font-weight:700; color:var(--text); }
|
||
.md-content hr { border:none; border-top:1px solid var(--border); margin:20px 0; }
|
||
.md-content a { color:var(--accent); text-decoration:underline; }
|
||
.md-content strong { color:var(--text); font-weight:700; }
|
||
.md-content em { color:var(--text-muted); }
|
||
.md-fallback { padding:30px; text-align:center; color:var(--text-muted); }
|
||
.md-fallback h3 { font-size:14px; color:var(--text); margin-bottom:10px; text-transform:none; letter-spacing:0; }
|
||
.md-fallback p { font-size:12px; line-height:1.6; margin-bottom:12px; }
|
||
.md-fallback code { background:var(--surface3); padding:2px 7px; border-radius:3px; font-size:11px; }
|
||
|
||
/* ── PROTOCOL BANNER ── */
|
||
.proto-banner { background:linear-gradient(90deg, var(--amber-soft), var(--red-soft)); border-bottom:1px solid var(--amber); padding:10px 22px; display:flex; align-items:center; gap:14px; flex-shrink:0; }
|
||
.proto-banner-text { flex:1; font-size:12px; color:var(--text); line-height:1.4; }
|
||
.proto-banner-text strong { color:var(--amber); }
|
||
.proto-banner-text code { background:rgba(0,0,0,.25); padding:1px 6px; border-radius:3px; font-size:11px; }
|
||
.proto-banner-btn { background:var(--accent); color:#fff; font-size:12px; font-weight:700; padding:6px 14px; border:none; border-radius:5px; cursor:pointer; }
|
||
.proto-banner-btn:hover { opacity:.85; }
|
||
.proto-banner-close { background:none; border:none; color:var(--text-muted); font-size:18px; cursor:pointer; padding:0 4px; }
|
||
|
||
::-webkit-scrollbar { width:5px; height:5px; }
|
||
::-webkit-scrollbar-track { background:transparent; }
|
||
::-webkit-scrollbar-thumb { background:var(--border2); border-radius:3px; }
|
||
|
||
/* ── IDEEN-SIDEBAR (rechts) ── */
|
||
.sidebar-right { width:280px; min-width:280px; background:var(--surface); border-left:1px solid var(--border); display:flex; flex-direction:column; overflow:hidden; }
|
||
.sr-head { padding:16px 14px 10px; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--text-muted); border-bottom:1px solid var(--border); flex-shrink:0; }
|
||
.sr-input-wrap { padding:10px 10px 6px; border-bottom:1px solid var(--border); flex-shrink:0; display:flex; flex-direction:column; gap:6px; }
|
||
.idea-input { width:100%; background:var(--surface2); border:1px solid var(--border2); color:var(--text); font-size:13px; padding:7px 10px; border-radius:5px; }
|
||
.idea-input:focus { outline:none; border-color:var(--accent); }
|
||
.idea-input::placeholder { color:var(--text-dim); }
|
||
.idea-tag-row { display:flex; gap:5px; }
|
||
.idea-tag-btn { flex:1; font-size:10px; font-weight:700; letter-spacing:.04em; padding:4px 0; border-radius:4px; border:1px solid var(--border2); background:var(--surface3); color:var(--text-dim); cursor:pointer; text-align:center; transition:all .1s; }
|
||
.idea-tag-btn.sel-projekt { background:var(--accent-soft); color:var(--accent); border-color:var(--accent); }
|
||
.idea-tag-btn.sel-erkenntnis { background:var(--teal-soft); color:var(--teal); border-color:var(--teal); }
|
||
.idea-tag-btn.sel-spaeter { background:var(--amber-soft); color:var(--amber); border-color:var(--amber); }
|
||
|
||
.ideas-list { flex:1; overflow-y:auto; padding:8px 8px; display:flex; flex-direction:column; gap:5px; }
|
||
.idea-card { background:var(--surface2); border:1px solid var(--border); border-radius:5px; padding:8px 9px; cursor:pointer; transition:border-color .1s; position:relative; }
|
||
.idea-card:hover { border-color:var(--border2); }
|
||
.idea-card:hover .idea-actions { opacity:1; }
|
||
.idea-text { font-size:13px; color:var(--text); line-height:1.4; padding-right:16px; }
|
||
.idea-meta { display:flex; align-items:center; gap:6px; margin-top:5px; }
|
||
.idea-tag { font-size:9px; font-weight:700; padding:2px 6px; border-radius:3px; letter-spacing:.04em; text-transform:uppercase; }
|
||
.it-projekt { background:var(--accent-soft); color:var(--accent); }
|
||
.it-erkenntnis { background:var(--teal-soft); color:var(--teal); }
|
||
.it-spaeter { background:var(--amber-soft); color:var(--amber); }
|
||
.idea-date { font-size:9.5px; color:var(--text-dim); }
|
||
.idea-actions { position:absolute; top:6px; right:6px; display:flex; gap:3px; opacity:0; transition:opacity .1s; }
|
||
.idea-btn { font-size:10px; padding:2px 5px; border-radius:3px; border:none; cursor:pointer; font-weight:700; }
|
||
.idea-btn-promote { background:var(--accent-soft); color:var(--accent); }
|
||
.idea-btn-del { background:var(--red-soft); color:var(--red); }
|
||
|
||
/* ── IDEEN FILTER ── */
|
||
.idea-filter-row { display:flex; gap:4px; margin-top:6px; }
|
||
.ifilter { flex:1; text-align:center; padding:4px 0; font-size:10px; font-weight:700; letter-spacing:.03em; border-radius:4px; cursor:pointer; background:var(--surface3); color:var(--text-dim); border:1px solid var(--border); user-select:none; transition:all .1s; }
|
||
.ifilter:hover { color:var(--text-muted); }
|
||
.if-all { color:var(--text-muted); }
|
||
.if-projekt { background:var(--accent-soft); color:var(--accent); border-color:var(--accent); }
|
||
.if-erkenntnis { background:var(--teal-soft); color:var(--teal); border-color:var(--teal); }
|
||
.if-spaeter { background:var(--amber-soft); color:var(--amber); border-color:var(--amber); }
|
||
.idea-count { font-size:9px; color:var(--text-dim); margin-top:4px; padding:0 2px; }
|
||
|
||
/* Promote-Modal */
|
||
.promote-overlay { position:fixed; inset:0; background:rgba(0,0,0,.5); display:flex; align-items:center; justify-content:center; z-index:100; }
|
||
.promote-box { background:var(--surface); border:1px solid var(--border2); border-radius:10px; padding:20px; width:320px; box-shadow:0 8px 32px rgba(0,0,0,.5); }
|
||
.promote-title { font-size:13px; font-weight:700; margin-bottom:4px; }
|
||
.promote-idea { font-size:11px; color:var(--text-muted); margin-bottom:14px; line-height:1.4; }
|
||
.promote-row { display:flex; flex-direction:column; gap:4px; margin-bottom:10px; }
|
||
.promote-lbl { font-size:10px; font-weight:700; letter-spacing:.06em; text-transform:uppercase; color:var(--text-dim); }
|
||
.promote-sel { background:var(--surface2); border:1px solid var(--border2); color:var(--text); font-size:12px; padding:6px 8px; border-radius:5px; }
|
||
.promote-sel:focus { outline:none; border-color:var(--accent); }
|
||
.promote-btns { display:flex; gap:8px; margin-top:14px; }
|
||
.promote-ok { flex:1; padding:7px; background:var(--accent); color:#fff; border:none; border-radius:5px; font-size:12px; font-weight:700; cursor:pointer; }
|
||
.promote-ok:hover { opacity:.85; }
|
||
.promote-cancel { padding:7px 12px; background:var(--surface2); border:1px solid var(--border2); color:var(--text-muted); border-radius:5px; font-size:12px; cursor:pointer; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div id="proto-banner" style="display:none;position:fixed;top:0;left:0;right:0;z-index:300" class="proto-banner">
|
||
<div class="proto-banner-text">
|
||
<strong>⚠ Du läufst gerade über <code>file://</code></strong> — der MD-Viewer und alle MD-Links funktionieren nur über den lokalen Server.
|
||
Wechsle zu <code>http://localhost:8765/dev/kanban/index.html</code> (Server läuft bereits im Hintergrund).
|
||
</div>
|
||
<button class="proto-banner-btn" onclick="window.location.href='http://localhost:8765/dev/kanban/index.html'">→ Wechseln</button>
|
||
<button class="proto-banner-close" onclick="document.getElementById('proto-banner').style.display='none'" title="Schließen">×</button>
|
||
</div>
|
||
|
||
<nav class="sidebar">
|
||
<div class="s-head">Robin's Projects</div>
|
||
<div id="sidebar-boards"></div>
|
||
<div class="s-actions">
|
||
<div class="s-add-btn" onclick="showAddBoard()">+ Board</div>
|
||
<div class="s-add-btn" onclick="showAddGroup()">+ Gruppe</div>
|
||
</div>
|
||
<div class="s-reset" onclick="resetCurrentBoard()">↺ Reset</div>
|
||
</nav>
|
||
|
||
<div class="main">
|
||
<div class="board-header">
|
||
<div class="board-title" id="hdr-title" contenteditable="true" spellcheck="false"
|
||
onblur="saveBoardName()"
|
||
onkeydown="if(event.key==='Enter'){event.preventDefault();this.blur()} else if(event.key==='Escape'){this.textContent=BOARDS[curId].name;this.blur()}"
|
||
title="Klick zum Umbenennen">—</div>
|
||
<div class="board-goal" id="hdr-goal" contenteditable="true" spellcheck="false"
|
||
onblur="saveBoardGoal()"
|
||
onkeydown="if(event.key==='Enter'){event.preventDefault();this.blur()} else if(event.key==='Escape'){this.textContent=BOARDS[curId].goal||'';this.blur()}"
|
||
title="Klick zum Bearbeiten"></div>
|
||
<div class="board-pills" id="hdr-pills"></div>
|
||
<div class="focus-row">
|
||
<span class="focus-star">★</span>
|
||
<span class="focus-label">Fokus</span>
|
||
<div class="focus-text" id="focus-text" contenteditable="true"
|
||
data-placeholder="Fixstern setzen — was gehört jetzt in Ready?"
|
||
onblur="saveFocus()" onkeydown="if(event.key==='Enter'){event.preventDefault();this.blur()}"></div>
|
||
</div>
|
||
</div>
|
||
<div class="view-tabs">
|
||
<div class="view-tab active" id="tab-overview" onclick="setView('overview')">Overview</div>
|
||
<div class="view-tab" id="tab-board" onclick="setView('board')">Board</div>
|
||
<div class="view-tab" id="tab-analytics" onclick="setView('analytics')">Analytics</div>
|
||
</div>
|
||
<div id="view-board"></div>
|
||
<div id="view-overview"></div>
|
||
<div id="view-analytics"></div>
|
||
</div>
|
||
|
||
<aside class="sidebar-right">
|
||
<div class="sr-head">💡 Ideen</div>
|
||
<div class="sr-input-wrap">
|
||
<input class="idea-input" id="idea-input" type="text" placeholder="Neue Idee… (Enter)"
|
||
onkeydown="if(event.key==='Enter')addIdea()">
|
||
<div class="idea-filter-row">
|
||
<div class="ifilter if-all" id="f-all" onclick="filterIdeas('all')">Alle</div>
|
||
<div class="ifilter" id="f-projekt" onclick="filterIdeas('projekt')">Projekt</div>
|
||
<div class="ifilter" id="f-erkenntnis" onclick="filterIdeas('erkenntnis')">Erkenntnis</div>
|
||
<div class="ifilter" id="f-spaeter" onclick="filterIdeas('spaeter')">Später</div>
|
||
</div>
|
||
<div class="idea-count" id="idea-count"></div>
|
||
</div>
|
||
<div class="ideas-list" id="ideas-list"></div>
|
||
<div class="ideas-drop-zones" id="ideas-drop-zones">
|
||
<div class="ideas-drop-zone dz-projekt" ondragover="onIdeaZoneDragOver(event,this)" ondrop="onDropToIdeas(event,'projekt')" ondragleave="this.classList.remove('drag-over')">📂 als Projekt</div>
|
||
<div class="ideas-drop-zone dz-erkenntnis" ondragover="onIdeaZoneDragOver(event,this)" ondrop="onDropToIdeas(event,'erkenntnis')" ondragleave="this.classList.remove('drag-over')">💡 als Erkenntnis</div>
|
||
<div class="ideas-drop-zone dz-spaeter" ondragover="onIdeaZoneDragOver(event,this)" ondrop="onDropToIdeas(event,'spaeter')" ondragleave="this.classList.remove('drag-over')">🕒 als Später</div>
|
||
<div class="ideas-drop-zone" ondragover="onIdeaZoneDragOver(event,this)" ondrop="onDropToIdeas(event,'')" ondragleave="this.classList.remove('drag-over')">○ ohne Label</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<div id="promote-overlay" style="display:none" class="promote-overlay" onclick="if(event.target===this)closePromote()">
|
||
<div class="promote-box">
|
||
<div class="promote-title">Als Task hinzufügen</div>
|
||
<div class="promote-idea" id="promote-idea-text"></div>
|
||
<div class="promote-row">
|
||
<div class="promote-lbl">Board</div>
|
||
<select class="promote-sel" id="promote-board" onchange="updatePromoteCols()"></select>
|
||
</div>
|
||
<div class="promote-row">
|
||
<div class="promote-lbl">Spalte</div>
|
||
<select class="promote-sel" id="promote-col"></select>
|
||
</div>
|
||
<div class="promote-btns">
|
||
<button class="promote-ok" onclick="confirmPromote()">Hinzufügen</button>
|
||
<button class="promote-cancel" onclick="closePromote()">Abbrechen</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="md-overlay" class="md-overlay" onclick="if(event.target===this)closeMD()">
|
||
<div class="md-box">
|
||
<div class="md-header">
|
||
<div class="md-path" id="md-path"></div>
|
||
<div class="md-actions">
|
||
<button class="md-close" onclick="openMdExternal()" title="In neuer Tab öffnen">↗ Tab</button>
|
||
<button class="md-close" onclick="closeMD()">× Schließen</button>
|
||
</div>
|
||
</div>
|
||
<div class="md-content" id="md-content"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="add-board-overlay" style="display:none" class="promote-overlay" onclick="if(event.target===this)closeAddBoard()">
|
||
<div class="promote-box" style="width:340px">
|
||
<div class="promote-title" id="ab-title">Neues Board anlegen</div>
|
||
<div class="promote-hint" id="ab-hint" style="display:none"></div>
|
||
<div class="promote-row">
|
||
<div class="promote-lbl">Name</div>
|
||
<input class="promote-sel" id="ab-name" type="text" placeholder="z.B. Neue App" style="width:100%" onkeydown="if(event.key==='Enter')confirmAddBoard()">
|
||
</div>
|
||
<div class="promote-row">
|
||
<div class="promote-lbl">Ziel / Beschreibung</div>
|
||
<input class="promote-sel" id="ab-goal" type="text" placeholder="Kurzer Kontext" style="width:100%">
|
||
</div>
|
||
<div class="promote-row">
|
||
<div class="promote-lbl">Gruppe</div>
|
||
<select class="promote-sel" id="ab-group" style="width:100%"></select>
|
||
</div>
|
||
<div class="promote-row">
|
||
<div class="promote-lbl">Farbe</div>
|
||
<div id="ab-colors" style="display:flex;gap:8px;flex-wrap:wrap;margin-top:4px"></div>
|
||
</div>
|
||
<div class="promote-btns">
|
||
<button class="promote-ok" onclick="confirmAddBoard()">Anlegen</button>
|
||
<button class="promote-cancel" onclick="closeAddBoard()">Abbrechen</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ── BOARD DATA ────────────────────────────────────────────────────────────
|
||
const TODAY = new Date('2026-05-19');
|
||
|
||
const DEFAULTS = {
|
||
ringsystem: {
|
||
name:'Ringsystem', goal:'Tool-agnostische Architektur — AGENTS.md kanonisch, CLAUDE.md/Codex/OpenCode als Symlinks. ARCHITECTURE.md definiert Ring 0–3.',
|
||
wipLimit:2, throughput:1, sle:{days:30,p:85},
|
||
overview: {
|
||
tagline:'Privacy-Schichten von innen (alles privat) nach außen (öffentlich).',
|
||
description:'Ring 0 (personal-vault) sieht alles und routet. Ring 1 trennt privat (robin-private) von beruflich (robin-work). Ring 2 = Sub-Projekte in Ring 1. Ring 3 = öffentliche Repos (doener-app, musichub, pleasance, …). Sichtbarkeit von innen nach außen — Referenzen NUR von außen nach innen.',
|
||
summary:'Tool-Agnostik durchgesetzt: AGENTS.md ist die kanonische Datei, jedes andere Tool greift via Symlink darauf zu (CLAUDE.md → AGENTS.md, gleiche Konvention für Codex, OpenCode). Skill-Files werden über ~/.skills/{name}.md zentral geteilt. Aktuell: globale ~/.claude/CLAUDE.md auf Symlink-Modell migriert.',
|
||
type:'meta',
|
||
launches:[
|
||
{label:'ARCHITECTURE.md', sub:'Ring-Topologie + Dispatch-Konvention', url:'http://localhost:8765/dev/personal-vault/ARCHITECTURE.md', icon:'🔄'},
|
||
{label:'Global AGENTS.md', sub:'~/.claude/AGENTS.md (= CLAUDE.md)', url:'http://localhost:8765/.claude/AGENTS.md', icon:'⚙'},
|
||
{label:'Ring 0 AGENTS.md', sub:'personal-vault', url:'http://localhost:8765/dev/personal-vault/AGENTS.md', icon:'⊙'},
|
||
{label:'Ring 1w AGENTS.md', sub:'robin-work', url:'http://localhost:8765/dev/robin-work/AGENTS.md', icon:'●'},
|
||
{label:'Ring 1p AGENTS.md', sub:'robin-private', url:'http://localhost:8765/dev/robin-private/AGENTS.md', icon:'○'},
|
||
{label:'Skills-Verzeichnis', sub:'~/.claude/skills/ (alle Skills)', url:'http://localhost:8765/.claude/skills/', icon:'🛠'},
|
||
{label:'Rules-Verzeichnis', sub:'~/.claude/rules/ (Verhalten)', url:'http://localhost:8765/.claude/rules/', icon:'📐'},
|
||
{label:'Kanban SPEC.md', sub:'~/dev/kanban/SPEC.md', url:'http://localhost:8765/dev/kanban/SPEC.md', icon:'📖'},
|
||
],
|
||
stack:['AGENTS.md (kanonisch)','CLAUDE.md (Symlink)','Codex/OpenCode (Symlink)','Skills via ~/.skills/','Dispatch via ~/.agent-signals/','Ring-Architektur'],
|
||
info:[
|
||
{label:'Naming-Konvention', value:'AGENTS.md ist Quelle; Tool-spezifische Files sind Symlinks darauf'},
|
||
{label:'Ring 0', value:'~/dev/personal-vault/ — Vault, sieht alles, niemand sieht hier rein'},
|
||
{label:'Ring 1 privat', value:'~/dev/robin-private/ — Privat-Workspace + Ring-2-Projekte'},
|
||
{label:'Ring 1 beruflich', value:'~/dev/robin-work/ — Business-Workspace + Ring-2-Projekte'},
|
||
{label:'Ring 2', value:'Sub-Projekte in Ring 1 — z.B. ~/dev/robin-work/projects/{name}/'},
|
||
{label:'Ring 3', value:'Öffentliche Repos in ~/dev/ direkt — doener-app, musichub, pleasance, mdim, openclaw, docpilot, ai-engineering'},
|
||
{label:'Skills', value:'~/.claude/skills/{name}/SKILL.md → Symlink auf ~/.skills/{name}.md (tool-agnostisch teilbar)'},
|
||
{label:'Dispatch zwischen Ringen', value:'~/.agent-signals/dispatch/{workspace}.dispatch.md'},
|
||
{label:'Sichtbarkeitsregel', value:'innen → außen: Ring 0 sieht alles, Ring 1 sieht Ring 2+3, Ring 2 sieht Ring 3'},
|
||
{label:'Referenz-Regel', value:'außen → innen: nur wissen DASS innere Ringe existieren, NIEMALS Inhalte lesen oder leaken'},
|
||
],
|
||
commands:[
|
||
{label:'Globale AGENTS lesen', cmd:'cat ~/.claude/AGENTS.md'},
|
||
{label:'ARCHITECTURE lesen', cmd:'cat ~/dev/personal-vault/ARCHITECTURE.md'},
|
||
{label:'Alle Skills auflisten', cmd:'ls ~/.claude/skills/'},
|
||
{label:'Symlink-Check', cmd:'find ~/dev/robin-work ~/dev/robin-private ~/dev/personal-vault -maxdepth 2 -name "CLAUDE.md" -type l'},
|
||
],
|
||
},
|
||
cols:[
|
||
{id:'ready', label:'Ready', tasks:[
|
||
{t:'AGENTS.md ## Aktueller Stand in allen Code-Repos auf 2-Bullet-Snapshot schrumpfen'},
|
||
{t:'Codex/OpenCode Symlink-Setup verifizieren oder anlegen'},
|
||
{t:'~/.skills/ als kanonischen Skill-Store dokumentieren'},
|
||
{t:'Ring-Mapping-Tabelle in ARCHITECTURE.md aktuell halten (cmux-toolkit, khala etc.)'},
|
||
]},
|
||
{id:'wip', label:'In Progress', tasks:[]},
|
||
{id:'done', label:'Done', tasks:[
|
||
{t:'~/.claude/CLAUDE.md → AGENTS.md migriert (Symlink-Konvention)',doneAt:1779580800000},
|
||
{t:'Ringsystem-Meta-Board angelegt mit Overview aller relevanten MD-Files',doneAt:1779580800000},
|
||
]},
|
||
],
|
||
},
|
||
kanban: {
|
||
name:'Kanban (dieses Board)', goal:'Self-contained HTML-Kanban — ~/dev/kanban/index.html. Visualisierung des Ringsystems, Single Source of Truth für laufende Projekte.',
|
||
wipLimit:2, throughput:3, sle:{days:7,p:85},
|
||
overview: {
|
||
tagline:'Autopoiesis — das System, das sich selbst beobachtet und steuert.',
|
||
description:'Single Source of Truth für alle laufenden Projekte. Visualisiert das Ringsystem (Ring 0 → Ring 3) als 5-Dimensionen-Layer: Gruppe (Domäne) × Ring (Privacy) × Board (Projekt) × Spalte (Flow-Status) × Karten-Class-of-Service. Self-contained HTML/CSS/JS, keine Build-Pipeline, kein Backend. State in localStorage. Anlehnung an Luhmanns Systemtheorie: das Kanban beobachtet sich selbst (dieses Meta-Board) und alle anderen Projekte gleichzeitig.',
|
||
summary:'Reifegrad: produktionsreif für tägliche Nutzung. Aktuell in Iteration: Overview-Pages pro Projekt mit Hero-Icons, Quick-Launch, Secrets, Quick-Commands. Pilot war Döner-App. Ausstehend: Overview-Daten für die restlichen Boards.',
|
||
type:'meta',
|
||
launches:[
|
||
{label:'Kanban öffnen', sub:'~/dev/kanban/index.html', url:'http://localhost:8765/dev/kanban/index.html', icon:'📋'},
|
||
{label:'Spec lesen', sub:'SPEC.md (Architektur + Test-Checkliste)', url:'http://localhost:8765/dev/kanban/SPEC.md', icon:'📖'},
|
||
{label:'ARCHITECTURE.md', sub:'Ring-System kanonisch', url:'http://localhost:8765/dev/personal-vault/ARCHITECTURE.md', icon:'🔄'},
|
||
{label:'CLAUDE.md global', sub:'Skill-Routing + Globals', url:'http://localhost:8765/.claude/CLAUDE.md', icon:'🤖'},
|
||
],
|
||
stack:['HTML5','CSS3 (Custom Properties)','Vanilla JS','HTML5 Drag & Drop API','localStorage','Monte-Carlo-Simulation','Little\'s Law','Luhmann-Autopoiesis'],
|
||
info:[
|
||
{label:'Datei', value:'/Users/robinchoice/dev/kanban/index.html'},
|
||
{label:'Spec', value:'/Users/robinchoice/dev/kanban/SPEC.md'},
|
||
{label:'Assets', value:'/Users/robinchoice/dev/kanban/assets/ (App-Icons)'},
|
||
{label:'localStorage', value:'kanban_v2 · kanban_groups · kanban_board_order · kanban_ideas · kanban_ideas_seeded'},
|
||
{label:'Tabs', value:'Overview (default) · Board · Analytics'},
|
||
{label:'Gruppen', value:'Meta · Code · Beruflich · Web · Privat'},
|
||
{label:'Ringe', value:'R0 (Meta) · R1p (privat) · R1w (beruflich) · R2 (Sub-Projekt) · R3 (öffentlich)'},
|
||
{label:'Seed-Version', value:'v3 — 16 neue Ideen aus _archive (Songideen, Bitcoin Bank, Cypherpunk, …)'},
|
||
],
|
||
commands:[
|
||
{label:'Kanban öffnen', cmd:'open ~/dev/kanban/index.html'},
|
||
{label:'Spec ansehen', cmd:'cat ~/dev/kanban/SPEC.md'},
|
||
{label:'localStorage komplett resetten', cmd:'echo \'localStorage.clear()\' // im Browser-Devtools-Console ausführen'},
|
||
{label:'Backup-Dump', cmd:'cp ~/dev/kanban/index.html ~/dev/kanban/backup-$(date +%Y%m%d).html'},
|
||
],
|
||
},
|
||
cols:[
|
||
{id:'ready', label:'Ready', tasks:[
|
||
{t:'CFD (Cumulative Flow Diagram) via tägliche Snapshots'},
|
||
{t:'Definition of Ready / Done pro Spalte editierbar'},
|
||
{t:'Class-of-Service-Mix-Chart in Analytics'},
|
||
{t:'PWA-Manifest für iPad Home-Screen (Stufe 1)'},
|
||
{t:'Sync-Layer (Cloudflare Worker + KV) für Multi-Device (Stufe 3)'},
|
||
{t:'AGENTS.md ## Aktueller Stand in allen Code-Repos auf 2-Bullet-Snapshot schrumpfen'},
|
||
{t:'Filesystem-Sync (optional): Node-Backend Boards ↔ Ordner'},
|
||
{t:'Card-Edit (Titel/Note direkt in der Karte)'},
|
||
{t:'Sortierung INNERHALB einer Spalte per Drag'},
|
||
{t:'Export / Backup als JSON-Dump'},
|
||
{t:'Overview-Daten für alle restlichen Boards (musichub, openclaw, docpilot, k4, pleasance, mdim, …)'},
|
||
]},
|
||
{id:'wip', label:'In Progress', tasks:[
|
||
{t:'Robin testet das Board auf Herz & Nieren'},
|
||
]},
|
||
{id:'done', label:'Done', tasks:[
|
||
{t:'WIP-Limit pro Board editierbar (Header-Pill)',doneAt:1779580800000},
|
||
{t:'Launch-Cards komplett klickbar (nicht nur ↗)',doneAt:1779577200000},
|
||
{t:'Overview komplett editierbar (alle Felder, Add/Remove Rows)',doneAt:1779573600000},
|
||
{t:'Auto-Aging — movedAt-Timestamp + Glow für überfällige Karten',doneAt:1779570000000},
|
||
{t:'Throughput-History — doneAt-Timestamp + reale 14-Tage-Berechnung',doneAt:1779566400000},
|
||
{t:'Flow Efficiency Berechnung korrigiert (WIP/(WIP+Ready))',doneAt:1779562800000},
|
||
{t:'Board-Name + Goal per Klick editierbar',doneAt:1779559200000},
|
||
{t:'kanban-mcp aus MCP-Config entfernt',doneAt:1779555600000},
|
||
{t:'_archive READMEs als DEPRECATED-Marker angelegt',doneAt:1779552000000},
|
||
{t:'Overview-Hero mit App-Icon + Tagline + Description',doneAt:1779494400000},
|
||
{t:'Tab-Reihenfolge: Overview · Board · Analytics',doneAt:1779494400000},
|
||
{t:'Overview-Pilot Döner-App (Quick-Launch, Hosting, Secrets, Commands)',doneAt:1779408000000},
|
||
{t:'Ring-Badge an Boards + Klick zum Wechseln',note:'R0/R1p/R1w/R2/R3 — Sidebar-Pill',doneAt:1779321600000},
|
||
{t:'Gruppen-Reihenfolge per Drag ändern',doneAt:1779321600000},
|
||
{t:'Task → Idee per Drag (Tag-Drop-Zone)',doneAt:1779321600000},
|
||
{t:'Meta-Gruppe + dieses Kanban-Board angelegt',doneAt:1779235200000},
|
||
{t:'SPEC.md komplett neu (Test-Checkliste für Tester-Agent)',doneAt:1779235200000},
|
||
{t:'Promote-Card-zu-Board (↗ Button)',doneAt:1779148800000},
|
||
{t:'Tiefenscan _archive → 16 neue Seed-Ideen v3',doneAt:1779148800000},
|
||
{t:'Card-Delete + Promote-Modal-Bug + Group-Create-Bug gefixt',doneAt:1779062400000},
|
||
{t:'Idee-DnD → Spalte + Board-Drag zwischen Gruppen + Board-Reorder',doneAt:1779062400000},
|
||
{t:'3-Spalten-Vereinfachung + Blocker-Flag statt Tags',doneAt:1778976000000},
|
||
{t:'Expedite-Swimlane (3 Zonen aligned zu Spalten)',doneAt:1778976000000},
|
||
{t:'Add-Card-Input + Add-Board-Modal + Add-Group',doneAt:1778889600000},
|
||
{t:'Ideen-Pinnwand rechts + 35 Seed-Ideen aus Ring-System',doneAt:1778889600000},
|
||
{t:'Monte-Carlo-Forecast + Kanban-KPIs (Cycle Time, Flow Efficiency, SLE)',doneAt:1778803200000},
|
||
{t:'localStorage-Persistierung + 5→3-Spalten-Migration',doneAt:1778803200000},
|
||
{t:'Initial-Setup: 14 Boards aus Robins Ring-System ingested',doneAt:1778716800000},
|
||
]},
|
||
],
|
||
},
|
||
doener: {
|
||
name:'Döner-App', goal:'iOS-App in Swift 6 + SwiftUI + MapKit. Ziel: tester-ready, dann App Store Launch.',
|
||
wipLimit:3, throughput:2, sle:{days:14,p:85},
|
||
overview: {
|
||
icon:'assets/doener.png',
|
||
tagline:'Find. Bewerte. Sammle. — eine Stempelkarte für Dönerläden.',
|
||
description:'iOS-App zum Finden, Bewerten und Sammeln von Dönerläden. Offline-First mit Community-Layer: Check-Ins, Sauce/Fleisch/Brot-Ratings, persönliche Döner-Geschichte und Live-Status von Freunden. Karte nutzt OpenStreetMap via Overpass-API mit SwiftData-Caching (24h-Refresh). Gamification-Mechaniken angelehnt an Pokémon Go und Spotify Wrapped.',
|
||
summary:'Sprint 3 — Launch-Readiness. Backend läuft auf Coolify (Hetzner). iOS-App vor dem Switch von LAN auf Prod-Backend. Sprints 1+2 fertig (Tester-Readiness + Backend-Sync + Freunde-Feed live). Aktuell: S3.2 Produktiv-Hosting in WIP, S3.8 Apple JWKS-Validierung als Security-Blocker.',
|
||
type:'ios-app',
|
||
launches:[
|
||
{label:'GitHub Repo', sub:'robinchoice/DoenerApp', url:'https://github.com/robinchoice/DoenerApp', icon:'⌥'},
|
||
{label:'Coolify Dashboard', sub:'App jdna5c4…', url:'https://coolify.diespaetzles.lol', icon:'🚀'},
|
||
{label:'App Store Connect', sub:'Beta noch nicht released', url:'https://appstoreconnect.apple.com', icon:'📱'},
|
||
{label:'Apple Developer', sub:'Approval ausstehend', url:'https://developer.apple.com/account', icon:'⚙'},
|
||
],
|
||
stack:['Swift 6','SwiftUI','MapKit','SwiftData','Vapor','PostgreSQL + PostGIS','Docker','Coolify','Hetzner','Overpass API'],
|
||
info:[
|
||
{label:'Repo-Pfad', value:'~/dev/doener-app'},
|
||
{label:'Backend-Stack', value:'Vapor (Swift) + PostgreSQL + PostGIS'},
|
||
{label:'Coolify App-UUID', value:'jdna5c4aqx6bf6u10bs5j48n'},
|
||
{label:'Coolify DB-UUID', value:'mzu4msj785xpe5nl6ypntb4d'},
|
||
{label:'Coolify-Instanz', value:'coolify.diespaetzles.lol'},
|
||
{label:'iOS APIConfig', value:'iOS/DoenerApp/.../APIConfig.swift (Switch LAN→Prod ausstehend)'},
|
||
{label:'Hosting', value:'Hetzner VPS + Coolify, deployed via git.diespaetzles.lol'},
|
||
{label:'Apple JWKS', value:'S3.8 — Signaturprüfung implementieren (SECURITY-BLOCKER)'},
|
||
],
|
||
secrets:[
|
||
{label:'COOLIFY_SPAETZLES_TOKEN', value:'(bitte aus ~/.secrets eintragen oder env-ref lassen)', masked:true},
|
||
{label:'GitHub Secret — COOLIFY_TOKEN', value:'(bitte eintragen)', masked:true},
|
||
{label:'GitHub Secret — COOLIFY_APP_UUID', value:'jdna5c4aqx6bf6u10bs5j48n'},
|
||
{label:'Apple Bundle ID', value:'(bitte eintragen — z.B. com.robinchoice.DoenerApp)'},
|
||
{label:'Apple Team ID', value:'(bitte eintragen)'},
|
||
],
|
||
commands:[
|
||
{label:'Deploy triggern', cmd:'curl -X POST -H "Authorization: Bearer $COOLIFY_SPAETZLES_TOKEN" https://coolify.diespaetzles.lol/api/v1/applications/jdna5c4aqx6bf6u10bs5j48n/start'},
|
||
{label:'Repo öffnen', cmd:'cd ~/dev/doener-app && code .'},
|
||
{label:'Logs anschauen', cmd:'curl -H "Authorization: Bearer $COOLIFY_SPAETZLES_TOKEN" https://coolify.diespaetzles.lol/api/v1/applications/jdna5c4aqx6bf6u10bs5j48n/logs'},
|
||
],
|
||
},
|
||
cols:[
|
||
{id:'ready', label:'Ready', tasks:[
|
||
{t:'S4.A Pokémon-Go Sammelmechanik'},{t:'S4.B Snapchat-Heatmap'},{t:'S4.C Döner-Symbole statt Sterne'},{t:'S4.D Community-Summary mit KI'},{t:'S4.E In-App Feedback für Tester'},
|
||
{t:'S3.5 Push Notifications'},{t:'S3.6 Freunde per Kontakte finden'},{t:'S3.7 Leaderboards'},
|
||
{t:'S3.8 Apple JWT Signaturprüfung',blocked:true,cos:'fixed'},
|
||
{t:'S3.3 Google Maps Places API',blocked:true,cos:'expedite'},
|
||
]},
|
||
{id:'wip', label:'In Progress', tasks:[{t:'S3.2 Produktiv-Hosting',blocked:true,cos:'expedite',age:30}]},
|
||
{id:'done', label:'Done', tasks:[{t:'Sprint 1: Tester-Readiness',note:'Sprint 1 komplett ✅'},{t:'Sprint 2: Backend-Sync',note:'Sprint 2 komplett ✅'},{t:'S3.4 Freunde-Feed + Live-Status',note:'Backend + iOS fertig, Build grün'}]},
|
||
]
|
||
},
|
||
musichub: {
|
||
name:'Music Hub', goal:'Label-Kollaborations-Webapp: SvelteKit + Hono + Postgres. Phase 3: Background Sync.',
|
||
wipLimit:3, throughput:3, sle:{days:21,p:85},
|
||
cols:[
|
||
{id:'ready', label:'Ready', tasks:[
|
||
{t:'Phase 3: Background Sync (IDB-Queue, SW sync-Handler)'},{t:'Onboarding-Role für Backend-Personalisierung nutzen'},{t:'DB is_public nach STEM-Tests auf privat setzen'},
|
||
{t:'RESEND_API_KEY setzen für E-Mail-Versand',blocked:true},
|
||
]},
|
||
{id:'wip', label:'In Progress', tasks:[]},
|
||
{id:'done', label:'Done', tasks:[{t:'PWA Phase 2: Push Notifications (VAPID)',note:'VAPID, push_subscriptions, SW-Handler'},{t:'Listen Analytics + Reject with Feedback',note:'IP-Hashing, sendBeacon'},{t:'SSE Real-time (EventSource, Pub/Sub)',note:'version:new / comment:new'},{t:'Onboarding Flow + Bottom Navigation Mobile',note:'3-Step Overlay, safe-area-aware'}]},
|
||
]
|
||
},
|
||
openclaw: {
|
||
name:'OpenClaw / Rob', goal:'Eigener 24/7-KI-Assistent auf Dell OptiPlex. Gemma 4, Telegram-Bridge, MCP-Tools.',
|
||
wipLimit:2, throughput:1, sle:{days:30,p:80},
|
||
cols:[
|
||
{id:'ready', label:'Ready', tasks:[
|
||
{t:'Whisper Voice-Messages Container stabilisieren'},{t:'TELEGRAM_CHAT_ID setzen'},
|
||
{t:'Modellwechsel + openclaw-upgrade',blocked:true,cos:'expedite'},
|
||
]},
|
||
{id:'wip', label:'In Progress', tasks:[]},
|
||
{id:'done', label:'Done', tasks:[]},
|
||
]
|
||
},
|
||
docpilot: {
|
||
name:'docpilot', goal:'Git-getriggerte Doku-Updates via Claude API. v1: README-Modus → PR.',
|
||
wipLimit:2, throughput:2, sle:{days:14,p:85},
|
||
cols:[
|
||
{id:'ready', label:'Ready', tasks:[
|
||
{t:'Spec schreiben: v1 README-Modus'},
|
||
{t:'Repo anlegen + CLAUDE.md einrichten'},{t:'GitHub Action: Diff → Claude → PR'},{t:'Erster Live-Test: Perpetual Traveler Repo'},
|
||
]},
|
||
{id:'wip', label:'In Progress', tasks:[]},
|
||
{id:'done', label:'Done', tasks:[]},
|
||
]
|
||
},
|
||
k4: {
|
||
name:'K4 Digital — PM-Mandat', goal:'Freelance PM bei K4 DIGITAL GmbH ab Mai 2026. NDA unterzeichnet.',
|
||
wipLimit:2, throughput:3, sle:{days:7,p:90},
|
||
cols:[
|
||
{id:'ready', label:'Ready', tasks:[{t:'Cheat Sheets fertigstellen'},{t:'Kurstag-Vorbereitung für morgen',cos:'fixed'}]},
|
||
{id:'wip', label:'In Progress', tasks:[{t:'PSK I Kurs bei K4 durchführen',age:2}]},
|
||
{id:'done', label:'Done', tasks:[{t:'NDA unterzeichnet'},{t:'PSK I Prüfung bestanden',note:'Eigene Zertifizierung ✅'}]},
|
||
]
|
||
},
|
||
branding: {
|
||
name:'Branding & Außendarstellung', goal:'Logo, Farbschema, Typo, Fotos, Angebots-/Rechnungstemplate.',
|
||
wipLimit:2, throughput:1, sle:{days:30,p:80},
|
||
cols:[
|
||
{id:'ready', label:'Ready', tasks:[{t:'Logo + Farbschema + Typo festlegen'},{t:'Professionelle Fotos — Termin vereinbaren'},{t:'Angebots-/Rechnungstemplate mit Pandoc+Typst'}]},
|
||
{id:'wip', label:'In Progress', tasks:[]},
|
||
{id:'done', label:'Done', tasks:[]},
|
||
]
|
||
},
|
||
psk: {
|
||
name:'PSK I Zertifizierung', goal:'PSK I bestanden ✅. Jetzt: Kurs bei K4 als Trainer durchführen.',
|
||
wipLimit:1, throughput:1, sle:{days:7,p:95},
|
||
cols:[
|
||
{id:'ready', label:'Ready', tasks:[]},
|
||
{id:'wip', label:'In Progress', tasks:[]},
|
||
{id:'done', label:'Done', tasks:[{t:'Lernplan durcharbeiten',note:'Zertifizierungsphase abgeschlossen'},{t:'Mock-Prüfung / Self-Assessment',note:'PSK I bestanden ✅'}]},
|
||
]
|
||
},
|
||
pleasance: {
|
||
name:'Pleasance', goal:'Atelier-Hub-Site: Kontaktformular gebaut. RESEND_API_KEY + DNS + weitere Seiten ausstehend.',
|
||
wipLimit:3, throughput:2, sle:{days:14,p:85},
|
||
cols:[
|
||
{id:'ready', label:'Ready', tasks:[
|
||
{t:'RESEND_API_KEY in Coolify setzen',blocked:true},{t:'DNS A-Record api.pleasance.org → VPS-IP'},
|
||
{t:'Hetzner VPS bestellen + Coolify installieren'},{t:'pleasance.org von Vercel → Coolify migrieren'},{t:'Inhalte aus pleasance-thoughts integrieren'},{t:'studio.html + buehne.html + projekte.html'},{t:'img/robin.jpg besorgen'},
|
||
]},
|
||
{id:'wip', label:'In Progress', tasks:[]},
|
||
{id:'done', label:'Done', tasks:[{t:'kontakt.html: Drei Doors → Formular'},{t:'coaching.html: alle Links → kontakt.html'},{t:'api/: Bun/Hono POST /contact mit Resend'},{t:'lernplatform-fork gelöscht',note:'Durch analyze-sources abgedeckt'}]},
|
||
]
|
||
},
|
||
mdim: {
|
||
name:'mydrugismusic Website', goal:'Website-Relaunch: Astro 5 + Directus CMS, deployed via Coolify.',
|
||
wipLimit:2, throughput:2, sle:{days:21,p:85},
|
||
cols:[
|
||
{id:'ready', label:'Ready', tasks:[{t:'Aktuellen Stand prüfen + offene Tasks erfassen'}]},
|
||
{id:'wip', label:'In Progress', tasks:[]},
|
||
{id:'done', label:'Done', tasks:[]},
|
||
]
|
||
},
|
||
eu: {
|
||
name:'Einzelunternehmen', goal:'Buchhaltung, Banking und operative Verwaltung des Einzelunternehmens.',
|
||
wipLimit:2, throughput:1, sle:{days:30,p:80},
|
||
cols:[
|
||
{id:'ready', label:'Ready', tasks:[{t:'Lexoffice anschauen + M26 sortieren'},{t:'Zweites Businesskonto eröffnen'}]},
|
||
{id:'wip', label:'In Progress', tasks:[]},
|
||
{id:'done', label:'Done', tasks:[]},
|
||
]
|
||
},
|
||
privat: {
|
||
name:'Haushalt & Leben', goal:'Persönliche Projekte, Haushalt, Anschaffungen.',
|
||
wipLimit:2, throughput:2, sle:{days:14,p:80},
|
||
cols:[
|
||
{id:'ready', label:'Ready', tasks:[{t:'Fahrradkette reparieren'},{t:'Analogkameras an die Wand bringen'},{t:'Bilder aufhängen'},{t:'Haken im Schlafzimmer aufhängen'},{t:'Korkpinwand fürs Büro kaufen'}]},
|
||
{id:'wip', label:'In Progress', tasks:[]},
|
||
{id:'done', label:'Done', tasks:[]},
|
||
]
|
||
},
|
||
degoogle: {
|
||
name:'De-Google / FOSS Migration', goal:'Migration zu self-hosted + FOSS. Nextcloud und Immich laufen bereits.',
|
||
wipLimit:2, throughput:1, sle:{days:30,p:80},
|
||
cols:[
|
||
{id:'ready', label:'Ready', tasks:[
|
||
{t:'Google Fotos → Immich Migration'},
|
||
{t:'Matrix/Conduwuit self-hosted + Bridges'},{t:'Google Meet → Jitsi Meet self-hosted'},{t:'Apple Podcasts → AntennaPod / Podverse'},{t:'Apple Bücher → KOReader + Calibre-Web'},{t:'VPN: Mulvad einrichten'},{t:'Mail-Server evaluieren: Stalwart Mail'},{t:'GrapheneOS Migration (iPhone → Pixel)'},
|
||
]},
|
||
{id:'wip', label:'In Progress', tasks:[{t:'Navigation: Organic Maps installieren',age:7}]},
|
||
{id:'done', label:'Done', tasks:[{t:'Proton Passwortmanager → Bitwarden',note:'256 Items nach Vaultwarden migriert'},{t:'Google Authenticator → Aegis / Raivo',note:'Alle 2FA-Codes nach Raivo OTP ✅'}]},
|
||
]
|
||
},
|
||
bibliothek: {
|
||
name:'Bibliothek-Pipeline', goal:'475 PDFs aus Nextcloud via /analyze-sources zu destillierten Markdown-Artikeln.',
|
||
wipLimit:2, throughput:2, sle:{days:14,p:85},
|
||
cols:[
|
||
{id:'ready', label:'Ready', tasks:[{t:'Pilot: E-Book mit analyze-sources verarbeiten'},{t:'Marker auf x86_64 Mac klären'}]},
|
||
{id:'wip', label:'In Progress', tasks:[]},
|
||
{id:'done', label:'Done', tasks:[]},
|
||
]
|
||
},
|
||
aikb: {
|
||
name:'AI Engineering KB', goal:'Wissens-Repo für AI Engineering nach LLM-Wiki-Pattern (Karpathy).',
|
||
wipLimit:2, throughput:2, sle:{days:14,p:85},
|
||
cols:[
|
||
{id:'ready', label:'Ready', tasks:[]},
|
||
{id:'wip', label:'In Progress', tasks:[]},
|
||
{id:'done', label:'Done', tasks:[{t:'Schema-Diff: source_type, author, year, isbn',note:'Frontmatter-Schema + Template dokumentiert'}]},
|
||
]
|
||
},
|
||
};
|
||
|
||
// ── BOARD META (group + color + ring for defaults) ────────────────────────
|
||
const BOARD_META = {
|
||
ringsystem:{group:'Meta', color:'#7c6af7', ring:'0'},
|
||
kanban: {group:'Meta', color:'#c084fc', ring:'3'},
|
||
doener: {group:'Code', color:'#f87171', ring:'3'},
|
||
musichub: {group:'Code', color:'#c084fc', ring:'1w'},
|
||
openclaw: {group:'Code', color:'#60a5fa', ring:'1w'},
|
||
docpilot: {group:'Code', color:'#7c6af7', ring:'1w'},
|
||
k4: {group:'Beruflich', color:'#fbbf24', ring:'1w'},
|
||
branding: {group:'Beruflich', color:'#4ade80', ring:'1w'},
|
||
psk: {group:'Beruflich', color:'#4ade80', ring:'1w'},
|
||
eu: {group:'Beruflich', color:'#fbbf24', ring:'1w'},
|
||
pleasance: {group:'Web', color:'#f87171', ring:'3'},
|
||
mdim: {group:'Web', color:'#c084fc', ring:'3'},
|
||
privat: {group:'Privat', color:'#fb923c', ring:'1p'},
|
||
degoogle: {group:'Privat', color:'#60a5fa', ring:'1p'},
|
||
bibliothek:{group:'Privat', color:'#7c6af7', ring:'1p'},
|
||
aikb: {group:'Privat', color:'#4ade80', ring:'1w'},
|
||
};
|
||
|
||
const RINGS = ['0','1p','1w','2','3','?'];
|
||
const RING_LABELS = {'0':'R0','1p':'R1p','1w':'R1w','2':'R2','3':'R3','?':'?'};
|
||
const PICK_COLORS = ['#f87171','#fb923c','#fbbf24','#4ade80','#2dd4bf','#60a5fa','#7c6af7','#c084fc'];
|
||
let GROUPS = ['Meta','Code','Beruflich','Web','Privat'];
|
||
|
||
function loadGroups() {
|
||
try { const g = JSON.parse(localStorage.getItem('kanban_groups')); if (Array.isArray(g)) GROUPS = g; } catch {}
|
||
if (!GROUPS.includes('Meta')) { GROUPS.unshift('Meta'); saveGroups(); }
|
||
}
|
||
function saveGroups() { localStorage.setItem('kanban_groups', JSON.stringify(GROUPS)); }
|
||
|
||
let BOARD_ORDER = {}; // {group: [id, ...]} — explicit ordering within groups
|
||
|
||
function loadBoardOrder() {
|
||
try { const o = JSON.parse(localStorage.getItem('kanban_board_order')); if (o && typeof o === 'object') BOARD_ORDER = o; } catch {}
|
||
}
|
||
function saveBoardOrder() { localStorage.setItem('kanban_board_order', JSON.stringify(BOARD_ORDER)); }
|
||
|
||
function getBoardsForGroup(g) {
|
||
const inGroup = Object.keys(BOARDS).filter(id => BOARDS[id] && BOARDS[id].group === g);
|
||
const ord = (BOARD_ORDER[g] || []).filter(id => inGroup.includes(id));
|
||
const rest = inGroup.filter(id => !ord.includes(id));
|
||
return [...ord, ...rest];
|
||
}
|
||
|
||
// ── STATE (localStorage) ──────────────────────────────────────────────────
|
||
let BOARDS = {};
|
||
|
||
function deepClone(o) { return JSON.parse(JSON.stringify(o)); }
|
||
|
||
function migrateCols(board) {
|
||
const ids = board.cols.map(c => c.id);
|
||
if (!ids.includes('onhold') && !ids.includes('backlog')) return;
|
||
const merged = [];
|
||
['onhold','backlog','ready'].forEach(oldId => {
|
||
const col = board.cols.find(c => c.id === oldId);
|
||
if (col) merged.push(...col.tasks);
|
||
});
|
||
const wip = board.cols.find(c => c.id === 'wip') || {tasks:[]};
|
||
const done = board.cols.find(c => c.id === 'done') || {tasks:[]};
|
||
board.cols = [
|
||
{id:'ready', label:'Ready', tasks: merged},
|
||
{id:'wip', label:'In Progress', tasks: wip.tasks},
|
||
{id:'done', label:'Done', tasks: done.tasks},
|
||
];
|
||
}
|
||
|
||
function loadState() {
|
||
BOARDS = deepClone(DEFAULTS);
|
||
Object.keys(BOARDS).forEach(id => {
|
||
const m = BOARD_META[id] || {group:'Privat', color:'#7c6af7', ring:'?'};
|
||
BOARDS[id].group = m.group;
|
||
BOARDS[id].color = m.color;
|
||
BOARDS[id].ring = m.ring || '?';
|
||
});
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem('kanban_v2'));
|
||
if (saved) {
|
||
Object.keys(saved).forEach(id => {
|
||
if (BOARDS[id]) {
|
||
if (saved[id].cols) { BOARDS[id].cols = saved[id].cols; migrateCols(BOARDS[id]); }
|
||
if (saved[id].focus !== undefined) BOARDS[id].focus = saved[id].focus;
|
||
if (saved[id].name) BOARDS[id].name = saved[id].name;
|
||
if (saved[id].goal !== undefined) BOARDS[id].goal = saved[id].goal;
|
||
if (saved[id].wipLimit) BOARDS[id].wipLimit = saved[id].wipLimit;
|
||
if (saved[id].throughput) BOARDS[id].throughput = saved[id].throughput;
|
||
if (saved[id].sle) BOARDS[id].sle = saved[id].sle;
|
||
if (saved[id].overview) BOARDS[id].overview = saved[id].overview;
|
||
} else if (saved[id].userCreated) {
|
||
BOARDS[id] = {
|
||
name: saved[id].name || 'Board',
|
||
goal: saved[id].goal || '',
|
||
group: saved[id].group || 'Privat',
|
||
color: saved[id].color || '#7c6af7',
|
||
ring: saved[id].ring || '?',
|
||
wipLimit: saved[id].wipLimit || 3,
|
||
throughput: saved[id].throughput || 2,
|
||
sle: saved[id].sle || {days:14,p:85},
|
||
cols: saved[id].cols || [{id:'ready',label:'Ready',tasks:[]},{id:'wip',label:'In Progress',tasks:[]},{id:'done',label:'Done',tasks:[]}],
|
||
focus: saved[id].focus || '',
|
||
userCreated: true,
|
||
};
|
||
migrateCols(BOARDS[id]);
|
||
if (!GROUPS.includes(BOARDS[id].group)) GROUPS.push(BOARDS[id].group);
|
||
} else if (saved[id].ring) {
|
||
BOARDS[id] && (BOARDS[id].ring = saved[id].ring);
|
||
}
|
||
});
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
|
||
function saveState() {
|
||
const snap = {};
|
||
Object.keys(BOARDS).forEach(id => {
|
||
const b = BOARDS[id];
|
||
snap[id] = {cols: b.cols, focus: b.focus || ''};
|
||
const def = DEFAULTS[id];
|
||
const defRing = BOARD_META[id]?.ring;
|
||
if (b.ring && b.ring !== defRing) snap[id].ring = b.ring;
|
||
if (def && b.name !== def.name) snap[id].name = b.name;
|
||
if (def && (b.goal || '') !== (def.goal || '')) snap[id].goal = b.goal;
|
||
if (def && b.wipLimit !== def.wipLimit) snap[id].wipLimit = b.wipLimit;
|
||
if (def && b.throughput !== def.throughput) snap[id].throughput = b.throughput;
|
||
if (def && JSON.stringify(b.sle) !== JSON.stringify(def.sle)) snap[id].sle = b.sle;
|
||
if (def && b.overview && JSON.stringify(b.overview) !== JSON.stringify(def.overview || {})) {
|
||
snap[id].overview = b.overview;
|
||
}
|
||
if (b.userCreated) Object.assign(snap[id], {
|
||
name:b.name, goal:b.goal, group:b.group, color:b.color, ring:b.ring,
|
||
wipLimit:b.wipLimit, throughput:b.throughput, sle:b.sle,
|
||
overview:b.overview, userCreated:true,
|
||
});
|
||
});
|
||
localStorage.setItem('kanban_v2', JSON.stringify(snap));
|
||
}
|
||
|
||
function saveFocus() {
|
||
const el = document.getElementById('focus-text');
|
||
if (!el) return;
|
||
BOARDS[curId].focus = el.textContent.trim();
|
||
saveState();
|
||
}
|
||
|
||
function saveBoardName() {
|
||
const el = document.getElementById('hdr-title');
|
||
if (!el) return;
|
||
const newName = el.textContent.trim();
|
||
if (!newName) { el.textContent = BOARDS[curId].name; return; }
|
||
if (newName === BOARDS[curId].name) return;
|
||
BOARDS[curId].name = newName;
|
||
saveState();
|
||
renderSidebar();
|
||
}
|
||
|
||
function saveBoardGoal() {
|
||
const el = document.getElementById('hdr-goal');
|
||
if (!el) return;
|
||
const newGoal = el.textContent.trim();
|
||
if (newGoal === (BOARDS[curId].goal || '')) return;
|
||
BOARDS[curId].goal = newGoal;
|
||
saveState();
|
||
}
|
||
|
||
function saveBoardSetting(key, el, min, max) {
|
||
const raw = parseFloat(el.textContent.replace(',','.'));
|
||
if (isNaN(raw) || raw < min || raw > max) {
|
||
el.textContent = key === 'sleDays' ? (BOARDS[curId].sle?.days||14) : BOARDS[curId][key];
|
||
return;
|
||
}
|
||
const val = key === 'throughput' ? raw : Math.round(raw);
|
||
if (key === 'sleDays') {
|
||
BOARDS[curId].sle = BOARDS[curId].sle || {p:85};
|
||
BOARDS[curId].sle.days = val;
|
||
} else {
|
||
BOARDS[curId][key] = val;
|
||
}
|
||
saveState();
|
||
show(curId);
|
||
if (curView === 'analytics') renderAnalytics(curId);
|
||
}
|
||
|
||
function resetCurrentBoard() {
|
||
const b = BOARDS[curId];
|
||
if (b.userCreated) {
|
||
if (!confirm(`"${b.name}" löschen?`)) return;
|
||
deleteBoard(curId);
|
||
return;
|
||
}
|
||
if (!confirm(`"${b.name}" auf Standardzustand zurücksetzen?`)) return;
|
||
BOARDS[curId].cols = deepClone(DEFAULTS[curId].cols);
|
||
saveState();
|
||
show(curId);
|
||
renderSidebar();
|
||
}
|
||
|
||
function deleteBoard(id, e) {
|
||
if (e) e.stopPropagation();
|
||
if (!confirm(`"${BOARDS[id].name}" löschen?`)) return;
|
||
delete BOARDS[id];
|
||
saveState();
|
||
const firstId = Object.keys(BOARDS)[0];
|
||
show(firstId);
|
||
renderSidebar();
|
||
}
|
||
|
||
function addCard(boardId, colId, text) {
|
||
const col = BOARDS[boardId].cols.find(c => c.id === colId);
|
||
if (!col) return;
|
||
const now = Date.now();
|
||
const task = {t: text, movedAt: now};
|
||
if (colId === 'done') task.doneAt = now;
|
||
col.tasks.push(task);
|
||
saveState();
|
||
show(boardId);
|
||
renderSidebar();
|
||
}
|
||
|
||
function deleteCard(boardId, colId, idx) {
|
||
const col = BOARDS[boardId].cols.find(c => c.id === colId);
|
||
if (!col) return;
|
||
col.tasks.splice(idx, 1);
|
||
saveState();
|
||
show(boardId);
|
||
renderSidebar();
|
||
}
|
||
|
||
function cycleRing(boardId, e) {
|
||
if (e) e.stopPropagation();
|
||
const b = BOARDS[boardId];
|
||
if (!b) return;
|
||
const cur = b.ring || '?';
|
||
const idx = RINGS.indexOf(cur);
|
||
b.ring = RINGS[(idx + 1) % RINGS.length];
|
||
saveState();
|
||
renderSidebar();
|
||
}
|
||
|
||
// ── BOARD ANLEGEN ─────────────────────────────────────────────────────────
|
||
let abSelColor = '#7c6af7';
|
||
let promoteSource = null; // {boardId, colId, idx} wenn ein Task zu Board werden soll
|
||
|
||
function promoteToBoard(boardId, colId, idx) {
|
||
const col = BOARDS[boardId].cols.find(c => c.id === colId);
|
||
if (!col || !col.tasks[idx]) return;
|
||
const task = col.tasks[idx];
|
||
promoteSource = {boardId, colId, idx};
|
||
showAddBoard();
|
||
document.getElementById('ab-name').value = task.t;
|
||
if (task.note) document.getElementById('ab-goal').value = task.note;
|
||
}
|
||
|
||
function showAddBoard() {
|
||
const grpSel = document.getElementById('ab-group');
|
||
grpSel.innerHTML = GROUPS.map(g => `<option value="${g}">${g}</option>`).join('')
|
||
+ `<option value="__new__">+ Neue Gruppe…</option>`;
|
||
if (promoteSource) {
|
||
const srcBoard = BOARDS[promoteSource.boardId];
|
||
grpSel.value = srcBoard.group;
|
||
abSelColor = srcBoard.color;
|
||
}
|
||
const colDiv = document.getElementById('ab-colors');
|
||
colDiv.innerHTML = PICK_COLORS.map(c =>
|
||
`<div onclick="selectAbColor('${c}',this)" style="width:20px;height:20px;border-radius:50%;background:${c};cursor:pointer;border:2px solid ${c===abSelColor?'#fff':'transparent'};flex-shrink:0"></div>`
|
||
).join('');
|
||
document.getElementById('ab-name').value = '';
|
||
document.getElementById('ab-goal').value = '';
|
||
|
||
const title = document.getElementById('ab-title');
|
||
const hint = document.getElementById('ab-hint');
|
||
if (promoteSource) {
|
||
const src = BOARDS[promoteSource.boardId];
|
||
const srcCol = src.cols.find(c => c.id === promoteSource.colId);
|
||
title.textContent = '↗ Task → neues Board';
|
||
hint.innerHTML = `Wird aus <strong>${src.name}</strong> · ${srcCol.label.replace('⚑ ','')} entfernt und als neues Board angelegt.`;
|
||
hint.style.display = '';
|
||
} else {
|
||
title.textContent = 'Neues Board anlegen';
|
||
hint.style.display = 'none';
|
||
}
|
||
|
||
document.getElementById('add-board-overlay').style.display = 'flex';
|
||
setTimeout(() => document.getElementById('ab-name').focus(), 50);
|
||
}
|
||
|
||
function selectAbColor(c, el) {
|
||
abSelColor = c;
|
||
document.querySelectorAll('#ab-colors div').forEach(d => d.style.borderColor = 'transparent');
|
||
el.style.borderColor = '#fff';
|
||
}
|
||
|
||
function confirmAddBoard() {
|
||
const name = document.getElementById('ab-name').value.trim();
|
||
if (!name) { document.getElementById('ab-name').focus(); return; }
|
||
const goal = document.getElementById('ab-goal').value.trim();
|
||
let group = document.getElementById('ab-group').value;
|
||
if (group === '__new__') {
|
||
const ng = prompt('Name der neuen Gruppe:');
|
||
if (!ng || !ng.trim()) return;
|
||
group = ng.trim();
|
||
if (!GROUPS.includes(group)) { GROUPS.push(group); saveGroups(); }
|
||
}
|
||
const id = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||
+ '-' + Date.now().toString(36);
|
||
BOARDS[id] = {
|
||
name, goal, group, color: abSelColor,
|
||
wipLimit:3, throughput:2, sle:{days:14,p:85},
|
||
cols:[{id:'ready',label:'Ready',tasks:[]},{id:'wip',label:'In Progress',tasks:[]},{id:'done',label:'Done',tasks:[]}],
|
||
focus:'', userCreated:true,
|
||
};
|
||
if (promoteSource) {
|
||
const srcCol = BOARDS[promoteSource.boardId]?.cols.find(c => c.id === promoteSource.colId);
|
||
if (srcCol) srcCol.tasks.splice(promoteSource.idx, 1);
|
||
promoteSource = null;
|
||
}
|
||
saveState();
|
||
closeAddBoard();
|
||
show(id);
|
||
renderSidebar();
|
||
}
|
||
|
||
function closeAddBoard() {
|
||
promoteSource = null;
|
||
document.getElementById('add-board-overlay').style.display = 'none';
|
||
}
|
||
|
||
function showAddGroup() {
|
||
const name = prompt('Name der neuen Gruppe:');
|
||
if (!name || !name.trim()) return;
|
||
const g = name.trim();
|
||
if (!GROUPS.includes(g)) { GROUPS.push(g); saveGroups(); renderSidebar(); }
|
||
}
|
||
|
||
// ── DRAG & DROP ───────────────────────────────────────────────────────────
|
||
let dragSrc = null;
|
||
let dragBoard = null;
|
||
let dragIdea = null;
|
||
let dragGroup = null;
|
||
|
||
function showIdeaDropZones(on) {
|
||
const el = document.getElementById('ideas-drop-zones');
|
||
if (el) el.classList.toggle('active', !!on);
|
||
}
|
||
|
||
function onDragStart(e, boardId, colId, idx) {
|
||
dragSrc = {boardId, colId, idx}; dragBoard = null; dragIdea = null; dragGroup = null;
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
setTimeout(() => e.target.classList.add('dragging'), 0);
|
||
showIdeaDropZones(true);
|
||
}
|
||
|
||
function onDragEnd(e) { e.target.classList.remove('dragging'); dragSrc = null; showIdeaDropZones(false); document.querySelectorAll('.ideas-drop-zone').forEach(z => z.classList.remove('drag-over')); }
|
||
|
||
function onDragOver(e, el) {
|
||
if (dragSrc === null && dragIdea === null) return;
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'move';
|
||
document.querySelectorAll('.col-body').forEach(b => b.classList.remove('drag-over'));
|
||
el.classList.add('drag-over');
|
||
}
|
||
|
||
// ── BOARD DRAG (sidebar groups) ───────────────────────────────────────────
|
||
function onBoardDragStart(e, boardId) {
|
||
dragBoard = boardId; dragSrc = null; dragIdea = null; dragGroup = null;
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
setTimeout(() => { const el = document.getElementById('nav-'+boardId); if(el) el.classList.add('board-dragging'); }, 0);
|
||
}
|
||
|
||
function onBoardDragEnd(e) {
|
||
if (dragBoard) { const el = document.getElementById('nav-'+dragBoard); if(el) el.classList.remove('board-dragging'); }
|
||
document.querySelectorAll('.s-group-section').forEach(s => s.classList.remove('drag-over'));
|
||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('drop-target'));
|
||
dragBoard = null;
|
||
}
|
||
|
||
function onGroupDragOver(e, el, group) {
|
||
if (dragBoard === null || BOARDS[dragBoard]?.group === group) return;
|
||
e.preventDefault();
|
||
document.querySelectorAll('.s-group-section').forEach(s => s.classList.remove('drag-over'));
|
||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('drop-target'));
|
||
el.classList.add('drag-over');
|
||
}
|
||
|
||
function onGroupDrop(e, group) {
|
||
e.preventDefault();
|
||
document.querySelectorAll('.s-group-section').forEach(s => s.classList.remove('drag-over'));
|
||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('drop-target'));
|
||
if (dragBoard === null) return;
|
||
const oldGroup = BOARDS[dragBoard].group;
|
||
BOARDS[dragBoard].group = group;
|
||
if (BOARD_ORDER[oldGroup]) BOARD_ORDER[oldGroup] = BOARD_ORDER[oldGroup].filter(id => id !== dragBoard);
|
||
if (!BOARD_ORDER[group]) BOARD_ORDER[group] = getBoardsForGroup(group).filter(id => id !== dragBoard);
|
||
BOARD_ORDER[group].push(dragBoard);
|
||
saveBoardOrder();
|
||
saveState();
|
||
const db = dragBoard; dragBoard = null;
|
||
renderSidebar();
|
||
}
|
||
|
||
function onNavItemDragOver(e, el, targetId, group) {
|
||
if (dragBoard === null || dragBoard === targetId) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('drop-target'));
|
||
document.querySelectorAll('.s-group-section').forEach(s => s.classList.remove('drag-over'));
|
||
el.classList.add('drop-target');
|
||
}
|
||
|
||
function onNavItemDrop(e, targetId, group) {
|
||
e.stopPropagation();
|
||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('drop-target'));
|
||
if (dragBoard === null || dragBoard === targetId) return;
|
||
e.preventDefault();
|
||
const srcGroup = BOARDS[dragBoard].group;
|
||
BOARDS[dragBoard].group = group;
|
||
const order = getBoardsForGroup(group).filter(id => id !== dragBoard);
|
||
const insertAt = order.indexOf(targetId);
|
||
if (insertAt === -1) order.push(dragBoard);
|
||
else order.splice(insertAt + 1, 0, dragBoard);
|
||
BOARD_ORDER[group] = order;
|
||
if (srcGroup !== group) {
|
||
BOARD_ORDER[srcGroup] = getBoardsForGroup(srcGroup).filter(id => id !== dragBoard);
|
||
saveState();
|
||
}
|
||
saveBoardOrder();
|
||
const db = dragBoard; dragBoard = null;
|
||
renderSidebar();
|
||
}
|
||
|
||
// ── IDEA DRAG (right sidebar → col-body) ─────────────────────────────────
|
||
function onIdeaDragStart(e, idx) {
|
||
dragIdea = idx; dragSrc = null; dragBoard = null; dragGroup = null;
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
setTimeout(() => e.target.classList.add('dragging'), 0);
|
||
}
|
||
|
||
function onIdeaDragEnd(e) { e.target.classList.remove('dragging'); dragIdea = null; }
|
||
|
||
// ── GROUP DRAG (reorder groups) ──────────────────────────────────────────
|
||
function onGroupHeaderDragStart(e, group) {
|
||
e.stopPropagation();
|
||
dragGroup = group; dragSrc = null; dragBoard = null; dragIdea = null;
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
setTimeout(() => e.target.classList.add('group-dragging'), 0);
|
||
}
|
||
|
||
function onGroupHeaderDragEnd(e) {
|
||
e.target.classList.remove('group-dragging');
|
||
document.querySelectorAll('.s-group-section').forEach(s => s.classList.remove('group-drop-target'));
|
||
dragGroup = null;
|
||
}
|
||
|
||
function onGroupSectionDragOver(e, el, group) {
|
||
if (dragGroup === null || dragGroup === group) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
document.querySelectorAll('.s-group-section').forEach(s => s.classList.remove('group-drop-target'));
|
||
el.classList.add('group-drop-target');
|
||
}
|
||
|
||
function onGroupSectionDrop(e, group) {
|
||
if (dragGroup === null || dragGroup === group) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
document.querySelectorAll('.s-group-section').forEach(s => s.classList.remove('group-drop-target'));
|
||
const from = GROUPS.indexOf(dragGroup);
|
||
const to = GROUPS.indexOf(group);
|
||
if (from === -1 || to === -1) return;
|
||
GROUPS.splice(from, 1);
|
||
GROUPS.splice(to, 0, dragGroup);
|
||
saveGroups();
|
||
dragGroup = null;
|
||
renderSidebar();
|
||
}
|
||
|
||
// ── TASK → IDEA (col → ideas sidebar) ────────────────────────────────────
|
||
function onIdeaZoneDragOver(e, el) {
|
||
if (dragSrc === null) return;
|
||
e.preventDefault();
|
||
document.querySelectorAll('.ideas-drop-zone').forEach(z => z.classList.remove('drag-over'));
|
||
el.classList.add('drag-over');
|
||
}
|
||
|
||
function onDropToIdeas(e, tag) {
|
||
if (dragSrc === null) return;
|
||
e.preventDefault();
|
||
document.querySelectorAll('.ideas-drop-zone').forEach(z => z.classList.remove('drag-over'));
|
||
const {boardId, colId, idx} = dragSrc;
|
||
const col = BOARDS[boardId]?.cols.find(c => c.id === colId);
|
||
if (!col || !col.tasks[idx]) { dragSrc = null; return; }
|
||
const task = col.tasks[idx];
|
||
col.tasks.splice(idx, 1);
|
||
const idea = {
|
||
id: Date.now(),
|
||
text: task.t,
|
||
tag: tag || 'projekt',
|
||
created: new Date().toLocaleDateString('de-DE',{day:'2-digit',month:'short'})
|
||
};
|
||
if (!tag) delete idea.tag;
|
||
IDEAS.unshift(idea);
|
||
saveState();
|
||
saveIdeas();
|
||
dragSrc = null;
|
||
showIdeaDropZones(false);
|
||
show(boardId);
|
||
renderSidebar();
|
||
renderIdeas();
|
||
}
|
||
|
||
function onDrop(e, boardId, colId) {
|
||
e.preventDefault();
|
||
document.querySelectorAll('.col-body,.exp-zone').forEach(b => b.classList.remove('drag-over'));
|
||
|
||
if (dragIdea !== null) {
|
||
const idea = IDEAS[dragIdea];
|
||
const col = BOARDS[boardId].cols.find(c => c.id === colId);
|
||
if (col && idea) {
|
||
col.tasks.push({t: idea.text});
|
||
saveState();
|
||
IDEAS.splice(dragIdea, 1);
|
||
saveIdeas();
|
||
show(boardId);
|
||
renderSidebar();
|
||
renderIdeas();
|
||
}
|
||
dragIdea = null;
|
||
return;
|
||
}
|
||
|
||
if (!dragSrc || dragSrc.boardId !== boardId) return;
|
||
const srcCol = BOARDS[boardId].cols.find(c => c.id === dragSrc.colId);
|
||
const dstCol = BOARDS[boardId].cols.find(c => c.id === colId);
|
||
if (!srcCol || !dstCol) return;
|
||
const task = srcCol.tasks[dragSrc.idx];
|
||
if (!task) return;
|
||
if (srcCol.id === dstCol.id) {
|
||
if (task.cos === 'expedite') { delete task.cos; saveState(); show(boardId); }
|
||
dragSrc = null;
|
||
return;
|
||
}
|
||
const [moved] = srcCol.tasks.splice(dragSrc.idx, 1);
|
||
delete moved.cos;
|
||
moved.movedAt = Date.now();
|
||
if (dstCol.id === 'done') moved.doneAt = Date.now();
|
||
dstCol.tasks.push(moved);
|
||
dragSrc = null;
|
||
saveState();
|
||
show(boardId);
|
||
initBadges();
|
||
}
|
||
|
||
function onDragOverExp(e, el) {
|
||
if (dragBoard !== null || dragIdea !== null) return;
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'move';
|
||
document.querySelectorAll('.exp-zone').forEach(z => z.classList.remove('drag-over'));
|
||
el.classList.add('drag-over');
|
||
}
|
||
|
||
function onDropToExpCol(e, boardId, colId) {
|
||
e.preventDefault();
|
||
document.querySelectorAll('.exp-zone,.col-body').forEach(el => el.classList.remove('drag-over'));
|
||
if (!dragSrc || dragSrc.boardId !== boardId) return;
|
||
const srcCol = BOARDS[boardId].cols.find(c => c.id === dragSrc.colId);
|
||
const dstCol = BOARDS[boardId].cols.find(c => c.id === colId);
|
||
if (!srcCol || !dstCol) return;
|
||
const [task] = srcCol.tasks.splice(dragSrc.idx, 1);
|
||
task.cos = 'expedite';
|
||
if (srcCol.id !== dstCol.id) task.movedAt = Date.now();
|
||
if (dstCol.id === 'done') task.doneAt = Date.now();
|
||
dstCol.tasks.push(task);
|
||
dragSrc = null;
|
||
saveState();
|
||
show(boardId);
|
||
initBadges();
|
||
}
|
||
|
||
function removeExpedite(boardId, colId, idx) {
|
||
const col = BOARDS[boardId].cols.find(c => c.id === colId);
|
||
if (col && col.tasks[idx]) { delete col.tasks[idx].cos; saveState(); show(boardId); }
|
||
}
|
||
|
||
// ── UTILS ─────────────────────────────────────────────────────────────────
|
||
function addWeeks(w) {
|
||
const d = new Date(TODAY);
|
||
d.setDate(d.getDate() + w * 7);
|
||
return d.toLocaleDateString('de-DE', {day:'2-digit', month:'short'});
|
||
}
|
||
|
||
function ageClass(days) {
|
||
if (!days) return null;
|
||
if (days <= 7) return 'age-fresh';
|
||
if (days <= 14) return 'age-ok';
|
||
if (days <= 30) return 'age-warn';
|
||
return 'age-old';
|
||
}
|
||
|
||
function tagHtml(tags) {
|
||
if (!tags || !tags.length) return '';
|
||
return tags.map(tag => {
|
||
if (tag === 'blocker') return '<span class="tag tag-bl">BLOCKER</span>';
|
||
if (tag === 'security-blocker') return '<span class="tag tag-sec">SECURITY</span>';
|
||
if (tag === 'ux-blocker') return '<span class="tag tag-ux">UX-BLOCK</span>';
|
||
return `<span class="tag tag-bl">${tag.toUpperCase()}</span>`;
|
||
}).join('');
|
||
}
|
||
|
||
function colCount(b, id) { return (b.cols.find(c => c.id === id)?.tasks || []).length; }
|
||
|
||
function remaining(b) {
|
||
return colCount(b,'ready') + colCount(b,'wip');
|
||
}
|
||
|
||
function hasBlocker(b) {
|
||
return b.cols.some(c => c.tasks.some(t => t.blocked));
|
||
}
|
||
|
||
function toggleBlocker(boardId, colId, idx) {
|
||
const col = BOARDS[boardId].cols.find(c => c.id === colId);
|
||
if (!col || !col.tasks[idx]) return;
|
||
const task = col.tasks[idx];
|
||
task.blocked = !task.blocked;
|
||
if (!task.blocked) delete task.blocked;
|
||
saveState();
|
||
show(boardId);
|
||
initBadges();
|
||
}
|
||
|
||
function getExpedite(b) {
|
||
const result = [];
|
||
b.cols.forEach(col => col.tasks.forEach(t => {
|
||
if (t.cos === 'expedite') result.push({task: t, col: col.label});
|
||
}));
|
||
return result;
|
||
}
|
||
|
||
// ── MONTE CARLO ───────────────────────────────────────────────────────────
|
||
function monteCarlo(left, tp, runs=8000) {
|
||
if (left <= 0) return {p50:0,p75:0,p85:0,p95:0};
|
||
const res = [];
|
||
for (let i=0; i<runs; i++) {
|
||
let rem=left, w=0;
|
||
while (rem > 0 && w < 104) {
|
||
rem -= Math.max(0, tp + (Math.random()-.5) * tp * 1.2);
|
||
w++;
|
||
}
|
||
res.push(w);
|
||
}
|
||
res.sort((a,b)=>a-b);
|
||
return {
|
||
p50: res[Math.floor(runs*.50)],
|
||
p75: res[Math.floor(runs*.75)],
|
||
p85: res[Math.floor(runs*.85)],
|
||
p95: res[Math.floor(runs*.95)],
|
||
};
|
||
}
|
||
|
||
// ── RENDER ────────────────────────────────────────────────────────────────
|
||
function liveAge(task) {
|
||
if (task.age) return task.age;
|
||
if (task.movedAt) return Math.floor((Date.now() - task.movedAt) / 86400000);
|
||
return null;
|
||
}
|
||
|
||
function cardHtml(task, boardId, colId, idx) {
|
||
const blk = task.blocked ? ' blocked' : '';
|
||
const cos = task.cos === 'expedite' ? ' cos-expedite' : task.cos === 'fixed' ? ' cos-fixed' : '';
|
||
const flagCls = task.blocked ? ' on' : '';
|
||
const age = liveAge(task);
|
||
const sle = BOARDS[boardId]?.sle?.days || 14;
|
||
const aging = age !== null && age > sle && colId !== 'done' ? ' aging' : '';
|
||
const ageCls = ageClass(age);
|
||
const agePart = (age !== null && colId !== 'done') ? `<div class="age-dot ${ageCls||''}"></div><span class="age-lbl ${aging?'age-warn-lbl':''}">${age}d${aging?' · ÜBERFÄLLIG':''}</span>` : '';
|
||
const meta = agePart ? `<div class="card-meta">${agePart}</div>` : '';
|
||
const note = task.note ? `<div class="done-note">${task.note}</div>` : '';
|
||
return `<div class="card${cos}${blk}${aging}" draggable="true"
|
||
ondragstart="onDragStart(event,'${boardId}','${colId}',${idx})"
|
||
ondragend="onDragEnd(event)">
|
||
<div style="display:flex;align-items:flex-start;gap:4px">
|
||
<div class="card-title" style="flex:1">${task.t}</div>
|
||
<button class="flag-btn${flagCls}" onclick="toggleBlocker('${boardId}','${colId}',${idx})" title="Blockiert">⚑</button>
|
||
<button class="card-promote-btn" onclick="promoteToBoard('${boardId}','${colId}',${idx})" title="Zu eigenem Board machen">↗</button>
|
||
<button class="card-del-btn" onclick="deleteCard('${boardId}','${colId}',${idx})" title="Löschen">×</button>
|
||
</div>${meta}${note}
|
||
</div>`;
|
||
}
|
||
|
||
function expCardHtml(task, boardId, colId, idx) {
|
||
const blk = task.blocked ? ' blocked' : '';
|
||
const flagCls = task.blocked ? ' on' : '';
|
||
const ageCls = ageClass(task.age);
|
||
const agePart = ageCls ? `<div class="age-dot ${ageCls}"></div><span class="age-lbl">${task.age}d</span>` : '';
|
||
const meta = agePart ? `<div class="card-meta">${agePart}</div>` : '';
|
||
return `<div class="card cos-expedite${blk}" draggable="true"
|
||
ondragstart="onDragStart(event,'${boardId}','${colId}',${idx})"
|
||
ondragend="onDragEnd(event)">
|
||
<div style="display:flex;align-items:flex-start;gap:4px">
|
||
<div class="card-title" style="flex:1">${task.t}</div>
|
||
<button class="flag-btn${flagCls}" onclick="toggleBlocker('${boardId}','${colId}',${idx})" title="Blockiert">⚑</button>
|
||
<button class="card-promote-btn" onclick="promoteToBoard('${boardId}','${colId}',${idx})" title="Zu eigenem Board machen">↗</button>
|
||
<button class="card-del-btn" onclick="deleteCard('${boardId}','${colId}',${idx})" title="Löschen">×</button>
|
||
</div>${meta}
|
||
<div class="card-meta" style="margin-top:3px"><span onclick="removeExpedite('${boardId}','${colId}',${idx})" style="font-size:9px;cursor:pointer;color:rgba(248,113,113,.5);user-select:none">↓ zurück</span></div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderBoard(id) {
|
||
const b = BOARDS[id];
|
||
const wipCount = colCount(b,'wip');
|
||
const wl = b.wipLimit;
|
||
const limCls = wipCount > wl ? 'lim-over' : wipCount === wl ? 'lim-warn' : 'lim-ok';
|
||
|
||
// Expedite swimlane — per-column zones aligned with board columns
|
||
const expZonesHtml = b.cols.map(col => {
|
||
const expTasks = col.tasks.map((t,i) => ({t,i})).filter(({t}) => t.cos === 'expedite');
|
||
const content = expTasks.length
|
||
? expTasks.map(({t,i}) => expCardHtml(t, id, col.id, i)).join('')
|
||
: `<div class="exp-zone-empty">↓ drop</div>`;
|
||
return `<div class="exp-zone"
|
||
ondragover="onDragOverExp(event,this)"
|
||
ondrop="onDropToExpCol(event,'${id}','${col.id}')"
|
||
ondragleave="this.classList.remove('drag-over')">${content}</div>`;
|
||
}).join('');
|
||
|
||
// Columns — only non-expedite cards
|
||
const colsHtml = b.cols.map(col => {
|
||
const isWip = col.id === 'wip';
|
||
const limitHtml = isWip
|
||
? `<span class="col-limit ${limCls}">${wipCount}/${wl}</span>`
|
||
: `<span class="col-limit lim-ok">${col.tasks.length}</span>`;
|
||
const normalTasks = col.tasks.map((t,i) => ({t,i})).filter(({t}) => t.cos !== 'expedite');
|
||
const cards = normalTasks.length
|
||
? normalTasks.map(({t,i}) => cardHtml(t, id, col.id, i)).join('')
|
||
: '<div class="empty-col">·</div>';
|
||
return `<div class="column col-${col.id}">
|
||
<div class="col-head"><span class="col-title">${col.label}</span>${limitHtml}</div>
|
||
<div class="col-body"
|
||
ondragover="onDragOver(event,this)"
|
||
ondrop="onDrop(event,'${id}','${col.id}')"
|
||
ondragleave="this.classList.remove('drag-over')">${cards}</div>
|
||
<input class="col-add-input" placeholder="+ Karte…"
|
||
onkeydown="if(event.key==='Enter'&&this.value.trim()){addCard('${id}','${col.id}',this.value.trim());this.value=''}
|
||
else if(event.key==='Escape')this.blur()">
|
||
</div>`;
|
||
}).join('');
|
||
|
||
document.getElementById('view-board').innerHTML = `
|
||
<div class="exp-header">⚑ EXPEDITE</div>
|
||
<div class="exp-swimlane">${expZonesHtml}</div>
|
||
<div class="col-sep"></div>
|
||
<div class="board">${colsHtml}</div>`;
|
||
}
|
||
|
||
function realThroughput(b, days=14) {
|
||
const doneCol = b.cols.find(c => c.id === 'done');
|
||
if (!doneCol) return null;
|
||
const cutoff = Date.now() - days * 86400000;
|
||
const recent = doneCol.tasks.filter(t => t.doneAt && t.doneAt >= cutoff).length;
|
||
if (recent === 0) return null;
|
||
return +(recent / days * 7).toFixed(1);
|
||
}
|
||
|
||
function renderAnalytics(id) {
|
||
const b = BOARDS[id];
|
||
const wip = colCount(b,'wip');
|
||
const rdy = colCount(b,'ready');
|
||
const done = colCount(b,'done');
|
||
const rem = wip + rdy;
|
||
const realTp = realThroughput(b);
|
||
const tp = realTp !== null ? realTp : b.throughput;
|
||
const wl = b.wipLimit;
|
||
|
||
// Little's Law: CT = WIP / Throughput (in Wochen) × 7 → Tage
|
||
const ct = wip > 0 ? Math.round(wip / tp * 7) : 0;
|
||
// Queue-ETA: wie lange bis Ready+WIP durch sind
|
||
const lt = rem > 0 ? Math.round(rem / tp * 7) : 0;
|
||
// Flow Efficiency Snapshot: aktiv / (aktiv + wartend) × 100
|
||
const fe = (wip + rdy) > 0 ? Math.round(wip / (wip + rdy) * 100) : 0;
|
||
const mc = monteCarlo(rem, tp);
|
||
|
||
const maxBar = Math.max(rdy, wip, done, 1);
|
||
const flowData = [
|
||
{lbl:'Ready', n:rdy, c:'var(--amber)'},
|
||
{lbl:'In Progress',n:wip, c:'var(--blue)'},
|
||
{lbl:'Done', n:done, c:'var(--green)'},
|
||
];
|
||
const flowRows = flowData.map(r => `<div class="flow-row">
|
||
<div class="flow-lbl">${r.lbl}</div>
|
||
<div class="flow-track"><div class="flow-fill" style="width:${Math.round(r.n/maxBar*100)}%;background:${r.c}"></div><div class="flow-n">${r.n}</div></div>
|
||
</div>`).join('');
|
||
|
||
const blockers = [];
|
||
b.cols.forEach(col => col.tasks.forEach(t => {
|
||
if (t.blocked) blockers.push({t:t.t, col:col.label});
|
||
}));
|
||
const blkHtml = blockers.length
|
||
? blockers.map(bl => `<div class="blk-item"><div class="blk-dot"></div><div><div class="blk-title">${bl.t}</div><div class="blk-board">${bl.col}</div></div></div>`).join('')
|
||
: '<div style="font-size:11px;color:var(--text-dim);padding:8px 0">Keine aktiven Blocker ✓</div>';
|
||
|
||
const sle = b.sle;
|
||
const sleOk = !ct || ct <= sle.days;
|
||
const sleBadge = sleOk
|
||
? `<span class="sle-badge" style="background:var(--green-soft);color:var(--green)">✓ OK</span>`
|
||
: `<span class="sle-badge" style="background:var(--red-soft);color:var(--red)">⚠ Risiko</span>`;
|
||
|
||
const wipClr = wip > wl ? 'kv-r' : wip === wl ? 'kv-a' : 'kv-b';
|
||
const feClr = fe >= 60 ? 'kv-g' : fe >= 30 ? 'kv-a' : 'kv-r';
|
||
|
||
document.getElementById('view-analytics').innerHTML = `
|
||
<div class="kpi-grid">
|
||
<div class="kpi"><div class="kpi-lbl">WIP</div><div class="kpi-val ${wipClr}">${wip}<span class="kpi-unit">/ ${wl}</span></div><div class="kpi-sub">${wip > wl ? '⚠ Limit überschritten' : wip === wl ? '~ Am Limit' : '✓ Im Limit'}</div></div>
|
||
<div class="kpi"><div class="kpi-lbl">Cycle Time ⌀</div><div class="kpi-val kv-b">${ct||'—'}<span class="kpi-unit">${ct?'d':''}</span></div><div class="kpi-sub">WIP / Throughput</div></div>
|
||
<div class="kpi"><div class="kpi-lbl">Queue ETA</div><div class="kpi-val kv-t">${lt||'—'}<span class="kpi-unit">${lt?'d':''}</span></div><div class="kpi-sub">Ready + WIP / TP</div></div>
|
||
<div class="kpi"><div class="kpi-lbl">Flow Efficiency</div><div class="kpi-val ${feClr}">${fe}<span class="kpi-unit">%</span></div><div class="kpi-sub">WIP / (WIP + Ready)</div></div>
|
||
<div class="kpi"><div class="kpi-lbl">Remaining</div><div class="kpi-val">${rem}<span class="kpi-unit">tasks</span></div><div class="kpi-sub">${done} abgeschlossen</div></div>
|
||
</div>
|
||
<div class="a-row two">
|
||
<div class="a-panel">
|
||
<div class="a-title">Flow Distribution</div>
|
||
<div class="flow-rows">${flowRows}</div>
|
||
</div>
|
||
<div class="a-panel">
|
||
<div class="a-title">Monte Carlo Forecast</div>
|
||
<div class="mc-ctrl">
|
||
<span class="mc-lbl">Throughput</span>
|
||
<input class="mc-inp" type="number" min="0.5" max="20" step="0.5" value="${tp}" id="mc-tp" oninput="updateMC('${id}',this.value)">
|
||
<span class="mc-lbl">Tasks/Woche — <strong>${rem}</strong> verbleibend</span>
|
||
<span class="mc-lbl" style="color:${realTp !== null ? 'var(--green)' : 'var(--text-dim)'};font-size:10px">${realTp !== null ? `↻ Real aus letzten 14d: ${realTp}/Woche` : `⊘ Statisch (noch keine Done-History)`}</span>
|
||
</div>
|
||
<div class="mc-grid" id="mc-grid">${mcCells(mc)}</div>
|
||
<div class="mc-range"><div class="mc-range-fill" style="width:100%"></div></div>
|
||
<div class="mc-note">8.000 Monte-Carlo-Runs · Throughput-Varianz ±60% · Kein Neuzugang einkalkuliert</div>
|
||
</div>
|
||
</div>
|
||
<div class="a-row two">
|
||
<div class="a-panel">
|
||
<div class="a-title">Active Blockers</div>
|
||
${blkHtml}
|
||
</div>
|
||
<div class="a-panel">
|
||
<div class="a-title">SLE — Service Level Expectation</div>
|
||
<div class="sle-item">
|
||
<div class="sle-text">${sle.p}% der Tasks in <strong>${sle.days} Tagen</strong> fertig.<br>Aktuelle Cycle Time: <strong>${ct||'—'}${ct?'d':''}</strong></div>
|
||
${sleBadge}
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function mcCells(mc) {
|
||
return [
|
||
{label:'P50', weeks:mc.p50, color:'var(--green)'},
|
||
{label:'P75', weeks:mc.p75, color:'var(--teal)'},
|
||
{label:'P85', weeks:mc.p85, color:'var(--amber)'},
|
||
{label:'P95', weeks:mc.p95, color:'var(--red)'},
|
||
].map(c => `<div class="mc-cell">
|
||
<div class="mc-pct">${c.label}</div>
|
||
<div class="mc-weeks" style="color:${c.color}">${c.weeks}w</div>
|
||
<div class="mc-date">${addWeeks(c.weeks)}</div>
|
||
</div>`).join('');
|
||
}
|
||
|
||
function updateMC(id, tpStr) {
|
||
const tp = parseFloat(tpStr) || 1;
|
||
const rem = remaining(BOARDS[id]);
|
||
document.getElementById('mc-grid').innerHTML = mcCells(monteCarlo(rem, tp));
|
||
}
|
||
|
||
// ── OVERVIEW ──────────────────────────────────────────────────────────────
|
||
function escapeHtml(s) {
|
||
return String(s)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
function renderOverview(id) {
|
||
const b = BOARDS[id];
|
||
if (!b.overview) b.overview = {};
|
||
const ov = b.overview;
|
||
const el = document.getElementById('view-overview');
|
||
|
||
const launches = (ov.launches || []).map((L,i) => `
|
||
<div class="ov-launch-card" style="position:relative" data-url="${escapeHtml(L.url || '')}" onclick="onLaunchClick(event,this)" title="Klick zum Öffnen · Doppelklick auf Text zum Editieren">
|
||
<div class="ov-launch-icon" data-section="launches" data-idx="${i}" data-prop="icon" ondblclick="enableEdit(this)" onblur="this.contentEditable='false';updateOvFromEl(this)">${escapeHtml(L.icon || '🔗')}</div>
|
||
<div class="ov-launch-meta">
|
||
<div class="ov-launch-lbl" data-section="launches" data-idx="${i}" data-prop="label" ondblclick="enableEdit(this)" onblur="this.contentEditable='false';updateOvFromEl(this)">${escapeHtml(L.label || '')}</div>
|
||
<div class="ov-launch-sub" data-section="launches" data-idx="${i}" data-prop="sub" ondblclick="enableEdit(this)" onblur="this.contentEditable='false';updateOvFromEl(this)">${escapeHtml(L.sub || '')}</div>
|
||
<div class="ov-launch-sub" style="margin-top:2px;opacity:.55;font-size:9.5px;word-break:break-all" data-section="launches" data-idx="${i}" data-prop="url" ondblclick="enableEdit(this)" onblur="this.contentEditable='false';updateOvFromEl(this)">${escapeHtml(L.url || '')}</div>
|
||
</div>
|
||
<button class="ov-row-del" style="opacity:1;align-self:flex-start" onclick="event.stopPropagation();deleteOvRow('launches',${i})" title="Entfernen">×</button>
|
||
</div>`).join('');
|
||
|
||
const stack = (ov.stack || []).map((s,i) => `
|
||
<span class="ov-pill-wrap">
|
||
<span class="ov-pill ov-edit" data-section="stack" data-idx="${i}" data-prop="" onblur="updateOvFromEl(this)" contenteditable="true">${escapeHtml(s)}</span>
|
||
<button class="ov-pill-del" onclick="deleteOvRow('stack',${i})" title="Entfernen">×</button>
|
||
</span>`).join('')
|
||
+ `<input class="ov-pill-input" placeholder="+ Tech" onkeydown="if(event.key==='Enter'&&this.value.trim()){addOvPill('stack',this.value.trim());this.value=''}">`;
|
||
|
||
const infoRows = (ov.info || []).map((it,i) => `
|
||
<div class="ov-info-row">
|
||
<div class="ov-info-lbl ov-edit" data-section="info" data-idx="${i}" data-prop="label" onblur="updateOvFromEl(this)" contenteditable="true" data-placeholder="Label">${escapeHtml(it.label || '')}</div>
|
||
<div class="ov-info-val ov-edit" data-section="info" data-idx="${i}" data-prop="value" onblur="updateOvFromEl(this)" contenteditable="true" data-placeholder="Wert">${escapeHtml(it.value || '')}</div>
|
||
<button class="ov-icon-btn" data-copy="${escapeHtml(it.value || '')}" onclick="copyFromBtn(this)" title="Kopieren">⧉</button>
|
||
<button class="ov-row-del" onclick="deleteOvRow('info',${i})" title="Entfernen">×</button>
|
||
</div>`).join('');
|
||
|
||
const secrets = (ov.secrets || []).map((s,i) => `
|
||
<div class="ov-info-row">
|
||
<div class="ov-info-lbl ov-edit" data-section="secrets" data-idx="${i}" data-prop="label" onblur="updateOvFromEl(this)" contenteditable="true" data-placeholder="Label">${escapeHtml(s.label || '')}</div>
|
||
<div class="ov-info-val ${s.masked?'masked':''} ov-edit" id="ov-sec-${i}" data-value="${escapeHtml(s.value || '')}" data-section="secrets" data-idx="${i}" data-prop="value" onblur="updateOvFromEl(this)" contenteditable="true" data-placeholder="Wert">${s.masked ? '••••••••••••••••' : escapeHtml(s.value || '')}</div>
|
||
<button class="ov-icon-btn" data-idx="${i}" onclick="toggleSecretBtn(this)" title="${s.masked?'Anzeigen':'Maskieren'}">${s.masked?'👁':'🙈'}</button>
|
||
<button class="ov-icon-btn" data-copy="${escapeHtml(s.value || '')}" onclick="copyFromBtn(this)" title="Kopieren">⧉</button>
|
||
<button class="ov-row-del" onclick="deleteOvRow('secrets',${i})" title="Entfernen">×</button>
|
||
</div>`).join('');
|
||
|
||
const commands = (ov.commands || []).map((c,i) => `
|
||
<div style="margin-bottom:10px">
|
||
<div class="ov-edit" style="font-size:10.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--text-muted);margin-bottom:4px" data-section="commands" data-idx="${i}" data-prop="label" onblur="updateOvFromEl(this)" contenteditable="true" data-placeholder="Label">${escapeHtml(c.label || '')}</div>
|
||
<div class="ov-cmd">
|
||
<div class="ov-cmd-text ov-edit" data-section="commands" data-idx="${i}" data-prop="cmd" onblur="updateOvFromEl(this)" contenteditable="true" data-placeholder="curl ...">${escapeHtml(c.cmd || '')}</div>
|
||
<button class="ov-icon-btn" data-copy="${escapeHtml(c.cmd || '')}" onclick="copyFromBtn(this)" title="Kopieren">⧉</button>
|
||
<button class="ov-row-del" onclick="deleteOvRow('commands',${i})" title="Entfernen">×</button>
|
||
</div>
|
||
</div>`).join('');
|
||
|
||
const ringLbl = RING_LABELS[b.ring || '?'] || '?';
|
||
const groupBadge = b.group ? `<span class="ov-hero-badge">${escapeHtml(b.group)}</span>` : '';
|
||
const ringBadge = `<span class="ov-hero-badge nav-ring r-${b.ring==='?'?'q':(b.ring||'q')}" style="cursor:default">${ringLbl}</span>`;
|
||
const typeBadge = ov.type ? `<span class="ov-hero-badge ov-edit" data-section="type" data-idx="" data-prop="" onblur="updateOvFromEl(this)" contenteditable="true">${escapeHtml(ov.type)}</span>` : '';
|
||
|
||
const heroIcon = ov.icon
|
||
? `<img class="ov-hero-icon" src="${escapeHtml(ov.icon)}" alt="${escapeHtml(b.name)} Icon" onerror="this.outerHTML='<div class=ov-hero-icon-fb style=background:${escapeHtml(b.color)}33;color:${escapeHtml(b.color)}>📋</div>'">`
|
||
: `<div class="ov-hero-icon-fb" style="background:${escapeHtml(b.color)}33;color:${escapeHtml(b.color)}">📋</div>`;
|
||
|
||
el.innerHTML = `
|
||
<div class="ov-hero">
|
||
${heroIcon}
|
||
<div class="ov-hero-body">
|
||
<div class="ov-hero-name">${escapeHtml(b.name)}</div>
|
||
<div class="ov-hero-tag ov-edit" data-section="tagline" data-idx="" data-prop="" onblur="updateOvFromEl(this)" contenteditable="true" data-placeholder="Tagline — eine kurze Beschreibung in einem Satz">${escapeHtml(ov.tagline || '')}</div>
|
||
<div class="ov-hero-desc ov-edit" data-section="description" data-idx="" data-prop="" onblur="updateOvFromEl(this)" contenteditable="true" data-placeholder="Längere Beschreibung — 2-3 Sätze, was genau gemacht wird">${escapeHtml(ov.description || '')}</div>
|
||
<div class="ov-hero-badges">${ringBadge}${groupBadge}${typeBadge || `<span class="ov-hero-badge ov-edit" data-section="type" data-idx="" data-prop="" onblur="updateOvFromEl(this)" contenteditable="true" data-placeholder="+ Typ">+ Typ</span>`}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ov-launch">${launches}</div>
|
||
<button class="ov-add-row" style="margin-bottom:18px" onclick="addOvRow('launches')">+ Launch-Card</button>
|
||
|
||
<div class="ov-row two">
|
||
<div class="ov-panel">
|
||
<div class="ov-title">Was bisher geschah</div>
|
||
<div class="ov-summary ov-edit" data-section="summary" data-idx="" data-prop="" onblur="updateOvFromEl(this)" contenteditable="true" data-placeholder="Status, Meilensteine, was läuft, was hängt — der lebendige Stand">${escapeHtml(ov.summary || '')}</div>
|
||
</div>
|
||
<div class="ov-panel">
|
||
<div class="ov-title">Tech-Stack</div>
|
||
<div class="ov-stack">${stack}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ov-row full">
|
||
<div class="ov-panel">
|
||
<div class="ov-title">Hosting · IDs · Pfade</div>
|
||
${infoRows}
|
||
<button class="ov-add-row" onclick="addOvRow('info')">+ Zeile</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ov-row full">
|
||
<div class="ov-panel">
|
||
<div class="ov-title">🔒 Secrets · Keys</div>
|
||
${secrets}
|
||
<button class="ov-add-row" onclick="addOvRow('secrets')">+ Secret</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ov-row full">
|
||
<div class="ov-panel">
|
||
<div class="ov-title">⌘ Quick-Commands</div>
|
||
${commands}
|
||
<button class="ov-add-row" onclick="addOvRow('commands')">+ Command</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function copyFromBtn(btn) {
|
||
copyText(btn.dataset.copy || '', btn);
|
||
}
|
||
|
||
function copyText(t, btn) {
|
||
navigator.clipboard.writeText(t).then(() => {
|
||
if (!btn) return;
|
||
const orig = btn.textContent;
|
||
btn.textContent = '✓';
|
||
btn.classList.add('copied');
|
||
setTimeout(() => { btn.textContent = orig; btn.classList.remove('copied'); }, 1200);
|
||
});
|
||
}
|
||
|
||
function toggleSecretBtn(btn) {
|
||
const idx = btn.dataset.idx;
|
||
const el = document.getElementById('ov-sec-' + idx);
|
||
if (!el) return;
|
||
const ov = BOARDS[curId].overview;
|
||
if (!ov || !ov.secrets || !ov.secrets[idx]) return;
|
||
ov.secrets[idx].masked = !ov.secrets[idx].masked;
|
||
saveState();
|
||
renderOverview(curId);
|
||
}
|
||
|
||
function updateOvFromEl(el) {
|
||
const section = el.dataset.section;
|
||
const idx = el.dataset.idx;
|
||
const prop = el.dataset.prop;
|
||
const value = el.textContent.trim();
|
||
if (!BOARDS[curId].overview) BOARDS[curId].overview = {};
|
||
const ov = BOARDS[curId].overview;
|
||
if (idx === '' || idx === undefined) {
|
||
ov[section] = value;
|
||
} else {
|
||
if (!ov[section]) ov[section] = [];
|
||
if (!ov[section][idx]) ov[section][idx] = {};
|
||
if (prop) ov[section][idx][prop] = value;
|
||
else ov[section][idx] = value;
|
||
}
|
||
saveState();
|
||
}
|
||
|
||
function addOvRow(section) {
|
||
if (!BOARDS[curId].overview) BOARDS[curId].overview = {};
|
||
const ov = BOARDS[curId].overview;
|
||
if (!ov[section]) ov[section] = [];
|
||
const blank = {
|
||
info: {label:'', value:''},
|
||
secrets: {label:'', value:'', masked:false},
|
||
launches: {label:'', sub:'', url:'', icon:'🔗'},
|
||
commands: {label:'', cmd:''},
|
||
}[section] || {};
|
||
ov[section].push(blank);
|
||
saveState();
|
||
renderOverview(curId);
|
||
}
|
||
|
||
function addOvPill(section, value) {
|
||
if (!BOARDS[curId].overview) BOARDS[curId].overview = {};
|
||
const ov = BOARDS[curId].overview;
|
||
if (!ov[section]) ov[section] = [];
|
||
ov[section].push(value);
|
||
saveState();
|
||
renderOverview(curId);
|
||
}
|
||
|
||
function deleteOvRow(section, idx) {
|
||
const ov = BOARDS[curId].overview;
|
||
if (!ov || !ov[section]) return;
|
||
ov[section].splice(idx, 1);
|
||
saveState();
|
||
renderOverview(curId);
|
||
}
|
||
|
||
function onLaunchClick(e, card) {
|
||
const t = e.target;
|
||
if (t.isContentEditable || (t.closest && t.closest('[contenteditable=true]'))) return;
|
||
if (t.tagName === 'BUTTON' || (t.closest && t.closest('button'))) return;
|
||
const url = card.dataset.url;
|
||
if (!url) return;
|
||
if (url.match(/\.md(\?|#|$)/i)) { openMD(url); return; }
|
||
window.open(url, '_blank');
|
||
}
|
||
|
||
function enableEdit(el) {
|
||
el.contentEditable = 'true';
|
||
el.focus();
|
||
const range = document.createRange();
|
||
range.selectNodeContents(el);
|
||
const sel = window.getSelection();
|
||
sel.removeAllRanges();
|
||
sel.addRange(range);
|
||
}
|
||
|
||
// ── MARKDOWN-VIEWER ──────────────────────────────────────────────────────
|
||
let mdCurrentUrl = null;
|
||
|
||
function openMD(url) {
|
||
mdCurrentUrl = url;
|
||
document.getElementById('md-path').textContent = url.replace(/^file:\/\//,'');
|
||
document.getElementById('md-content').innerHTML = '<div class="md-fallback"><p>Lade…</p></div>';
|
||
document.getElementById('md-overlay').classList.add('active');
|
||
fetch(url).then(r => {
|
||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||
return r.text();
|
||
}).then(text => {
|
||
document.getElementById('md-content').innerHTML = renderMarkdown(text);
|
||
}).catch(err => {
|
||
document.getElementById('md-content').innerHTML = `
|
||
<div class="md-fallback">
|
||
<h3>Inhalt kann nicht direkt geladen werden</h3>
|
||
<p>Browser blockieren <code>file://</code> Fetches aus Sicherheitsgründen (CORS).</p>
|
||
<p>Optionen:</p>
|
||
<ul style="text-align:left;display:inline-block;margin-top:8px">
|
||
<li>Markdown-Viewer Browser-Extension installieren (z.B. „Markdown Viewer Webext")</li>
|
||
<li>Lokalen HTTP-Server starten: <code>~/dev/kanban/server.sh &</code><br>Dann das Kanban über <code>http://localhost:8765/dev/kanban/index.html</code> aufrufen</li>
|
||
<li>Per <code>↗ Tab</code> oben rechts in neuer Tab öffnen (zeigt Roh-Text)</li>
|
||
</ul>
|
||
<p style="margin-top:14px;color:var(--text-dim);font-size:10.5px">Fehler: ${escapeHtml(err.message)}</p>
|
||
</div>`;
|
||
});
|
||
}
|
||
|
||
function openMdExternal() {
|
||
if (mdCurrentUrl) window.open(mdCurrentUrl, '_blank');
|
||
}
|
||
|
||
function closeMD() {
|
||
mdCurrentUrl = null;
|
||
document.getElementById('md-overlay').classList.remove('active');
|
||
}
|
||
|
||
// Mini-Markdown-Parser (Headings, Bold/Italic, Code, Lists, Links, Tables, Blockquotes, HR)
|
||
function renderMarkdown(src) {
|
||
// Escape HTML first
|
||
let s = escapeHtml(src);
|
||
|
||
// Code blocks ```
|
||
s = s.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => `<pre><code class="lang-${lang}">${code}</code></pre>`);
|
||
|
||
// Tables (very basic)
|
||
s = s.replace(/^\|(.+)\|\s*\n\|([\s:|-]+)\|\s*\n((?:\|.*\|\s*\n?)+)/gm, (match) => {
|
||
const lines = match.trim().split('\n');
|
||
const header = lines[0].split('|').slice(1,-1).map(c => `<th>${c.trim()}</th>`).join('');
|
||
const rows = lines.slice(2).map(line => '<tr>' + line.split('|').slice(1,-1).map(c => `<td>${c.trim()}</td>`).join('') + '</tr>').join('');
|
||
return `<table><thead><tr>${header}</tr></thead><tbody>${rows}</tbody></table>`;
|
||
});
|
||
|
||
// Headings
|
||
s = s.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
||
s = s.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
||
s = s.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
||
|
||
// HR
|
||
s = s.replace(/^---+$/gm, '<hr>');
|
||
|
||
// Blockquotes
|
||
s = s.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>');
|
||
|
||
// Lists (process line-by-line for nesting basics)
|
||
const lines = s.split('\n');
|
||
let out = []; let inUl = false; let inOl = false;
|
||
for (let line of lines) {
|
||
const ulMatch = line.match(/^[-*] (.+)$/);
|
||
const olMatch = line.match(/^\d+\. (.+)$/);
|
||
if (ulMatch) {
|
||
if (!inUl) { out.push('<ul>'); inUl = true; }
|
||
if (inOl) { out.push('</ol>'); inOl = false; }
|
||
out.push('<li>' + ulMatch[1] + '</li>');
|
||
} else if (olMatch) {
|
||
if (!inOl) { out.push('<ol>'); inOl = true; }
|
||
if (inUl) { out.push('</ul>'); inUl = false; }
|
||
out.push('<li>' + olMatch[1] + '</li>');
|
||
} else {
|
||
if (inUl) { out.push('</ul>'); inUl = false; }
|
||
if (inOl) { out.push('</ol>'); inOl = false; }
|
||
out.push(line);
|
||
}
|
||
}
|
||
if (inUl) out.push('</ul>');
|
||
if (inOl) out.push('</ol>');
|
||
s = out.join('\n');
|
||
|
||
// Inline: bold, italic, code, links
|
||
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||
s = s.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
|
||
|
||
// Paragraphs (wrap non-block lines)
|
||
s = s.split(/\n\n+/).map(block => {
|
||
if (block.match(/^<(h\d|ul|ol|pre|blockquote|table|hr)/)) return block;
|
||
if (block.trim() === '') return '';
|
||
return '<p>' + block.replace(/\n/g, '<br>') + '</p>';
|
||
}).join('\n');
|
||
|
||
return s;
|
||
}
|
||
|
||
// ── NAVIGATION ────────────────────────────────────────────────────────────
|
||
let curId = 'doener';
|
||
let curView = 'overview';
|
||
|
||
function setView(v) {
|
||
curView = v;
|
||
document.getElementById('tab-board').classList.toggle('active', v==='board');
|
||
document.getElementById('tab-overview').classList.toggle('active', v==='overview');
|
||
document.getElementById('tab-analytics').classList.toggle('active', v==='analytics');
|
||
const bEl = document.getElementById('view-board');
|
||
const oEl = document.getElementById('view-overview');
|
||
const aEl = document.getElementById('view-analytics');
|
||
bEl.style.display = v==='board' ? 'flex' : 'none';
|
||
oEl.style.display = v==='overview' ? 'block' : 'none';
|
||
aEl.style.display = v==='analytics' ? 'block' : 'none';
|
||
if (v === 'overview') renderOverview(curId);
|
||
if (v === 'analytics') renderAnalytics(curId);
|
||
}
|
||
|
||
function show(id) {
|
||
curId = id;
|
||
document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
|
||
const nav = document.getElementById('nav-' + id);
|
||
if (nav) nav.classList.add('active');
|
||
|
||
const b = BOARDS[id];
|
||
document.getElementById('hdr-title').textContent = b.name;
|
||
document.getElementById('hdr-goal').textContent = b.goal;
|
||
|
||
const wip = colCount(b,'wip');
|
||
const rdy = colCount(b,'ready');
|
||
const done = colCount(b,'done');
|
||
const blk = hasBlocker(b);
|
||
const wipLimitPill = `<span class="pill-edit" title="WIP-Limit für In Progress · klicken zum Ändern">WIP-Limit: <span class="pill-edit-val" contenteditable="true" spellcheck="false"
|
||
onblur="saveBoardSetting('wipLimit',this,1,20)"
|
||
onkeydown="if(event.key==='Enter'){event.preventDefault();this.blur()}">${b.wipLimit}</span></span>`;
|
||
const tpPill = `<span class="pill-edit" title="Throughput-Default (Tasks/Woche) · echter Wert kommt aus Done-History">TP: <span class="pill-edit-val" contenteditable="true" spellcheck="false"
|
||
onblur="saveBoardSetting('throughput',this,0.5,30)"
|
||
onkeydown="if(event.key==='Enter'){event.preventDefault();this.blur()}">${b.throughput}</span><span style="font-size:9.5px;opacity:.5;margin-left:1px">/wk</span></span>`;
|
||
const slePill = `<span class="pill-edit" title="Service Level Expectation in Tagen · überfällige Karten leuchten rot">SLE: <span class="pill-edit-val" contenteditable="true" spellcheck="false"
|
||
onblur="saveBoardSetting('sleDays',this,1,180)"
|
||
onkeydown="if(event.key==='Enter'){event.preventDefault();this.blur()}">${b.sle?.days||14}</span><span style="font-size:9.5px;opacity:.5;margin-left:1px">d</span></span>`;
|
||
const pills = [
|
||
wipLimitPill, tpPill, slePill,
|
||
wip ? `<span class="pill" style="background:var(--blue-soft);color:var(--blue)">${wip} in progress</span>` : '',
|
||
rdy ? `<span class="pill" style="background:var(--amber-soft);color:var(--amber)">${rdy} ready</span>` : '',
|
||
done ? `<span class="pill" style="background:var(--green-soft);color:var(--green)">${done} done</span>` : '',
|
||
blk ? `<span class="pill" style="background:var(--red-soft);color:var(--red)">⚑ blockiert</span>` : '',
|
||
].join('');
|
||
document.getElementById('hdr-pills').innerHTML = pills;
|
||
|
||
// Fokus laden
|
||
const focusEl = document.getElementById('focus-text');
|
||
if (focusEl) focusEl.textContent = b.focus || '';
|
||
|
||
renderBoard(id);
|
||
if (curView === 'overview') renderOverview(id);
|
||
if (curView === 'analytics') renderAnalytics(id);
|
||
}
|
||
|
||
function renderSidebar() {
|
||
let html = '';
|
||
GROUPS.forEach(g => {
|
||
const ids = getBoardsForGroup(g);
|
||
const metaCls = g === 'Meta' ? ' meta' : '';
|
||
html += `<div class="s-group-section${metaCls}"
|
||
ondragover="onGroupDragOver(event,this,'${g}'); onGroupSectionDragOver(event,this,'${g}')"
|
||
ondrop="onGroupDrop(event,'${g}'); onGroupSectionDrop(event,'${g}')"
|
||
ondragleave="document.querySelectorAll('.s-group-section').forEach(s=>s.classList.remove('drag-over'))">
|
||
<div class="s-group" draggable="true"
|
||
ondragstart="onGroupHeaderDragStart(event,'${g}')"
|
||
ondragend="onGroupHeaderDragEnd(event)">${g}</div>`;
|
||
if (!ids.length) {
|
||
html += `<div style="padding:4px 14px 6px;font-size:11px;color:var(--text-dim);font-style:italic">Leer — Board hinzufügen</div>`;
|
||
}
|
||
ids.forEach(id => {
|
||
const b = BOARDS[id];
|
||
const active = id === curId ? ' active' : '';
|
||
const rem = remaining(b);
|
||
const blk = hasBlocker(b);
|
||
const badgeCls = blk ? ' blk' : colCount(b,'wip') > 0 ? ' wip' : '';
|
||
const del = b.userCreated
|
||
? `<span class="nav-del" onclick="deleteBoard('${id}',event)">×</span>` : '';
|
||
const ring = b.ring || '?';
|
||
const ringCls = ring === '?' ? 'r-q' : 'r-' + ring;
|
||
const ringLbl = RING_LABELS[ring] || '?';
|
||
html += `<div class="nav-item${active}" draggable="true"
|
||
ondragstart="onBoardDragStart(event,'${id}')"
|
||
ondragend="onBoardDragEnd(event)"
|
||
ondragover="onNavItemDragOver(event,this,'${id}','${g}')"
|
||
ondrop="onNavItemDrop(event,'${id}','${g}')"
|
||
onclick="show('${id}')" id="nav-${id}">
|
||
<div class="nav-dot" style="background:${b.color}"></div>
|
||
<span class="nav-name">${b.name}</span>${del}
|
||
<span class="nav-ring ${ringCls}" onclick="cycleRing('${id}',event)" title="Ring wechseln">${ringLbl}</span>
|
||
<span class="nav-badge${badgeCls}" id="b-${id}">${rem||''}</span>
|
||
</div>`;
|
||
});
|
||
html += `</div>`;
|
||
});
|
||
const el = document.getElementById('sidebar-boards');
|
||
if (el) el.innerHTML = html;
|
||
}
|
||
|
||
function initBadges() { renderSidebar(); }
|
||
|
||
// ── IDEEN ────────────────────────────────────────────────────────────────
|
||
let IDEAS = [];
|
||
let selTag = 'projekt';
|
||
let ideaFilter = 'all';
|
||
let promoteIdx = null;
|
||
|
||
const SEED_IDEAS = [
|
||
// Aus robin-work/inbox/project-ideas.md
|
||
{id:1001, text:'NotebookLM ↔ Claude Code Workflow-Skill', tag:'projekt', created:'robin-work/inbox'},
|
||
{id:1002, text:'Streaming als Einnahmequelle — Musik/Content monetarisieren', tag:'projekt', created:'robin-work/inbox'},
|
||
{id:1003, text:'HTWKM Freiburg — Hochschule für Technik, Kunst & Musik (Moonshot)', tag:'spaeter', created:'robin-work/inbox'},
|
||
{id:1004, text:'Schlupflöcher — Cashback & Spartipps-Content/Tool', tag:'projekt', created:'robin-work/inbox'},
|
||
{id:1005, text:'AI Booking-Assistent für mydrugismusic-Label', tag:'projekt', created:'robin-work/inbox'},
|
||
{id:1006, text:'FOSS Contribution — Signal, Ardour oder Start9', tag:'spaeter', created:'robin-work/inbox'},
|
||
{id:1007, text:'Desktop-App-Portfolio: Rust/Slint + C++/Qt + Kotlin', tag:'spaeter', created:'robin-work/inbox'},
|
||
// Aus goals/2026.md
|
||
{id:1008, text:'SAP AI-first Equivalent — Enterprise-Disruption Moonshot (Konzept-Stub anlegen)', tag:'spaeter', created:'goals/2026'},
|
||
{id:1009, text:'AI-first Stack Migration — eigenen Tool-Stack konsequent umstellen', tag:'projekt', created:'goals/2026'},
|
||
// Aus robin-work/projects/_archive/
|
||
{id:1010, text:'Web-Design-Services als Pleasance-Angebot ausbauen', tag:'projekt', created:'archive/web-design'},
|
||
{id:1011, text:'Booking-Agentur aufbauen', tag:'spaeter', created:'archive/booking-agentur'},
|
||
{id:1012, text:'Russisch & Kyrillisch lernen', tag:'spaeter', created:'archive/russisch'},
|
||
// Aus robin-private/projects/_archive/Songideen.md
|
||
{id:1013, text:'EP "Hans Klein" aufnehmen — Paul McCartney, RHCP, FNM, Carole King, Cat Stevens Covers', tag:'projekt', created:'archive/songideen'},
|
||
{id:1014, text:'Trance Song aufnehmen (dauerschleifender Charakter, Trip-Text)', tag:'projekt', created:'archive/songideen'},
|
||
{id:1015, text:'Sommer Song aufnehmen', tag:'projekt', created:'archive/songideen'},
|
||
{id:1016, text:'Libra Songs: Traum vom Exil, Do what feels good, Trees, Lass es gut sein', tag:'projekt', created:'archive/songideen'},
|
||
// Aus eurorack-reparatur.md
|
||
{id:1017, text:'Eurorack-Reparatur-Skill aufbauen + Lötstationen-Setup', tag:'projekt', created:'robin-private/projects'},
|
||
{id:1018, text:'Langfristig: eigene DIY Eurorack-Module bauen (KiCad lernen)', tag:'spaeter', created:'robin-private/projects'},
|
||
// Aus Saarcar.md
|
||
{id:1019, text:'Saarcar-Konzept: Beatknöpfe an Lenkrad/Gangschaltung/Sitz', tag:'erkenntnis', created:'robin-work/Saarcar'},
|
||
{id:1020, text:'Saarcar: Fahrtwind als Energiequelle', tag:'erkenntnis', created:'robin-work/Saarcar'},
|
||
// Aus journal/Meine Lebensmodularität.md
|
||
{id:2001, text:'Von Fragmentierung zur Systemintegration — viele gute Ideen, aber noch nicht als Gesamtsystem verzahnt', tag:'erkenntnis', created:'journal/Lebensmodularität'},
|
||
{id:2002, text:'Strategisches Reduktionsprinzip: Welche Elemente haben den höchsten ROI für Kreativität, Flow & Wirkung?', tag:'erkenntnis', created:'journal/Lebensmodularität'},
|
||
{id:2003, text:'Finalisierung & Veröffentlichung ist die größte Schwäche — täglich Feuer für was anderes, viele offene Baustellen', tag:'erkenntnis', created:'journal/Lebensmodularität'},
|
||
{id:2004, text:'Soziale Wirkungs-Multiplikation: hochindividuelle Tools & Konzepte salonfähig machen, damit andere drauf aufbauen können', tag:'erkenntnis', created:'journal/Lebensmodularität'},
|
||
{id:2005, text:'Kapitalstruktur fehlt: konkrete Einkommensströme/Hebelmechanismen aufbauen die mit wenig Aufwand skalieren', tag:'erkenntnis', created:'journal/Lebensmodularität'},
|
||
// Aus journal/Gedanken am Mittag.md
|
||
{id:2006, text:'Körper-Geist-Seele Unternehmensmodell: Asset-Gesellschaft (Körper) + Operating-Gesellschaft (Seele) + Strategische Gesellschaft (Geist)', tag:'erkenntnis', created:'journal/Gedanken am Mittag'},
|
||
{id:2007, text:'Trinitätsprinzip: Alles in 3 aufteilen — 3x3 Orte, 3x3 Reisearten, 3x3 Wertschöpfungsarten', tag:'erkenntnis', created:'journal/Gedanken am Mittag'},
|
||
{id:2008, text:'"Sei die Person, die du dir selbst gewünscht hättest"', tag:'erkenntnis', created:'journal/Gesammelte Gedanken'},
|
||
{id:2009, text:'Sparen statt steuern', tag:'erkenntnis', created:'journal/Gesammelte Gedanken'},
|
||
// Aus journal/Gedanken im Flugzeug.md
|
||
{id:2010, text:'Musik muss Hobby bleiben — nur dann bleibt die Muse erhalten', tag:'erkenntnis', created:'journal/Flugzeug'},
|
||
{id:2011, text:'Je objektiver Leistung bewertet werden kann, desto leichter Bekanntheit (physikalischer Durchbruch > Modedesign)', tag:'erkenntnis', created:'journal/Flugzeug'},
|
||
{id:2012, text:'Langfristiges Ziel: Model Company werden und vermarkten', tag:'spaeter', created:'journal/Flugzeug'},
|
||
{id:2013, text:'HTTP 402 + Lightning als einfachster Einstieg in Bitcoin-Softwareprototypen', tag:'erkenntnis', created:'journal/Flugzeug'},
|
||
// Aus journal/Der AI Dschungel is real.md
|
||
{id:2014, text:'Convenience vs Sovereignty — Abwägung bei AI-Tools: proprietäre Bequemlichkeit vs. Selbstbestimmung', tag:'erkenntnis', created:'journal/AI Dschungel'},
|
||
{id:2015, text:'Open Source AI hält mit, ist aber noch nicht alltagstauglich für Breite — lokale Modelle langsamer, kleinerer Kontext', tag:'erkenntnis', created:'journal/AI Dschungel'},
|
||
|
||
// ── v3 — Archive-Tiefenscan (robin-work/_archive + robin-private/_archive) ──
|
||
// Restliche Songideen
|
||
{id:3001, text:'Song about loneliness — "Alone again" als Inspiration', tag:'projekt', created:'archive/songideen'},
|
||
{id:3002, text:'Nickelback-Mashup Song — Riffs aus Follow you home, Animals, Side of a Bullet, Next Contestant', tag:'projekt', created:'archive/songideen'},
|
||
// Aus robin-work/_archive
|
||
{id:3003, text:'Bitcoin-only Bank: Verleih + Investment-Plattform mit dezentralen Wertspeichern (Argentarius-Zirkulationsprinzip)', tag:'projekt', created:'archive/Bitcoin Bank'},
|
||
{id:3004, text:'GitHub Live Documentation mit AI Voice-Over — jeder Code-Change updated Doku automatisch', tag:'projekt', created:'archive/Digital Documentation'},
|
||
{id:3005, text:'Forschungsprojekt "Vom Ursprung zur Emergenz" — 7-Stufen-Modell (Wahrnehmung→Energie→Materie→…→Emergenz)', tag:'projekt', created:'archive/Forschungsprojekt'},
|
||
{id:3006, text:'Perpetual Traveler — Wissensbasis als docpilot-Showcase: Gesetze automatisch scannen + updaten', tag:'projekt', created:'archive/perpetual-traveler'},
|
||
{id:3007, text:'Bitcoin-Vortrag halten — Geldgeschichte, Krieg, Machtmissbrauch (Stock-to-Flow, Rai-Steine)', tag:'projekt', created:'archive/Bitcoin-Vortrag'},
|
||
{id:3008, text:'Exit-Strategie Deutschland systematisieren — Staatenlos-Framework anwenden + Custom-Gewichtung', tag:'projekt', created:'archive/exit-strategie'},
|
||
{id:3009, text:'Feedback-System für App-Testing — Telegram-Bot empfängt Feedback, schreibt in repo inbox/', tag:'projekt', created:'archive/feedback-system'},
|
||
{id:3010, text:'Coaching Landingpage live bringen (~/dev/coaching-landingpage)', tag:'projekt', created:'archive/coaching-landingpage'},
|
||
{id:3011, text:'Pleasance MVP in 14 Tagen — Studio (Webdesign) + Bühne (Musik) + Projekte als drei Türen', tag:'projekt', created:'archive/pleasance-strategie'},
|
||
// Erkenntnisse aus Archive
|
||
{id:3012, text:'Cypherpunk Wishlist als Geschäftsfelder: P2P-Markt, anonymes Marketing, Online-Reputation', tag:'erkenntnis', created:'archive/Cypherpunk'},
|
||
{id:3013, text:'Data-Science Heimprojekt-Pattern: Scrape → SQLite-Tagesdumps → Parse separat → Pandas/Feather', tag:'erkenntnis', created:'archive/Data Science'},
|
||
{id:3014, text:'4-Quadranten-Wissensmap (Tolle/Tesla/Nietzsche/Smith) — innen/außen × individuell/kollektiv als Sortier-Framework', tag:'erkenntnis', created:'archive/Theory of all Knowledge'},
|
||
{id:3015, text:'Schreibthemen: Musik als Vibration/Gefühle/Energie + "Das dritte Jahrtausend" (Dezentralisierung, Resonanz)', tag:'erkenntnis', created:'archive/Themen zum Schreiben'},
|
||
{id:3016, text:'Fear-Setting (Ferriss) als wiederverwendbares Decision-Framework: Define/Prevent/Repair-Tabelle', tag:'erkenntnis', created:'archive/fear-setting'},
|
||
];
|
||
|
||
const SEED_VERSION = 'v3';
|
||
|
||
function loadIdeas() {
|
||
try { IDEAS = JSON.parse(localStorage.getItem('kanban_ideas')) || []; } catch { IDEAS = []; }
|
||
const seeded = localStorage.getItem('kanban_ideas_seeded');
|
||
if (seeded !== SEED_VERSION) {
|
||
const existingIds = new Set(IDEAS.map(i => i.id));
|
||
SEED_IDEAS.forEach(s => { if (!existingIds.has(s.id)) IDEAS.push(s); });
|
||
IDEAS.sort((a,b) => (a.id||0) - (b.id||0));
|
||
saveIdeas();
|
||
localStorage.setItem('kanban_ideas_seeded', SEED_VERSION);
|
||
}
|
||
}
|
||
function saveIdeas() { localStorage.setItem('kanban_ideas', JSON.stringify(IDEAS)); }
|
||
|
||
|
||
function addIdea() {
|
||
const inp = document.getElementById('idea-input');
|
||
const text = inp.value.trim();
|
||
if (!text) return;
|
||
IDEAS.unshift({ id: Date.now(), text, tag: selTag, created: new Date().toLocaleDateString('de-DE', {day:'2-digit',month:'short'}) });
|
||
saveIdeas();
|
||
inp.value = '';
|
||
renderIdeas();
|
||
}
|
||
|
||
function deleteIdea(idx) {
|
||
IDEAS.splice(idx, 1);
|
||
saveIdeas();
|
||
renderIdeas();
|
||
}
|
||
|
||
function openPromote(idx) {
|
||
promoteIdx = idx;
|
||
const idea = IDEAS[idx];
|
||
document.getElementById('promote-idea-text').textContent = idea.text;
|
||
const bSel = document.getElementById('promote-board');
|
||
bSel.innerHTML = Object.entries(BOARDS).map(([id, b]) => `<option value="${id}">${b.name}</option>`).join('');
|
||
bSel.value = curId;
|
||
updatePromoteCols();
|
||
document.getElementById('promote-overlay').style.display = 'flex';
|
||
}
|
||
|
||
function updatePromoteCols() {
|
||
const id = document.getElementById('promote-board').value;
|
||
const b = BOARDS[id];
|
||
const colSel = document.getElementById('promote-col');
|
||
const validCols = b.cols.filter(c => c.id !== 'done');
|
||
colSel.innerHTML = validCols.map(c => `<option value="${c.id}">${c.label.replace('⚑ ','')}</option>`).join('');
|
||
if (validCols.length) colSel.value = validCols[0].id;
|
||
}
|
||
|
||
function confirmPromote() {
|
||
if (promoteIdx === null) return;
|
||
const idea = IDEAS[promoteIdx];
|
||
const boardId = document.getElementById('promote-board').value;
|
||
const colId = document.getElementById('promote-col').value;
|
||
const col = BOARDS[boardId].cols.find(c => c.id === colId);
|
||
if (!col) return;
|
||
col.tasks.push({ t: idea.text }); saveState(); initBadges(); if (curId === boardId) show(boardId);
|
||
IDEAS.splice(promoteIdx, 1);
|
||
saveIdeas();
|
||
promoteIdx = null;
|
||
document.getElementById('promote-overlay').style.display = 'none';
|
||
renderIdeas();
|
||
}
|
||
|
||
function closePromote() {
|
||
promoteIdx = null;
|
||
document.getElementById('promote-overlay').style.display = 'none';
|
||
}
|
||
|
||
function filterIdeas(tag) {
|
||
ideaFilter = tag;
|
||
if (tag !== 'all') selTag = tag;
|
||
['all','projekt','erkenntnis','spaeter'].forEach(t => {
|
||
const el = document.getElementById('f-' + t);
|
||
if (!el) return;
|
||
el.className = 'ifilter' + (t === tag ? ' if-' + t : '');
|
||
});
|
||
renderIdeas();
|
||
}
|
||
|
||
function renderIdeas() {
|
||
const list = document.getElementById('ideas-list');
|
||
const countEl = document.getElementById('idea-count');
|
||
const tagCls = { projekt:'it-projekt', erkenntnis:'it-erkenntnis', spaeter:'it-spaeter' };
|
||
const tagLbl = { projekt:'Projekt', erkenntnis:'Erkenntnis', spaeter:'Später' };
|
||
|
||
const filtered = ideaFilter === 'all' ? IDEAS : IDEAS.filter(i => i.tag === ideaFilter);
|
||
if (countEl) countEl.textContent = ideaFilter === 'all'
|
||
? `${IDEAS.length} Ideen gesamt`
|
||
: `${filtered.length} von ${IDEAS.length}`;
|
||
|
||
if (!filtered.length) {
|
||
list.innerHTML = `<div style="font-size:11px;color:var(--text-dim);padding:12px 4px;line-height:1.5">${IDEAS.length ? 'Keine Treffer für diesen Filter.' : 'Noch keine Ideen — einfach oben eintippen.'}</div>`;
|
||
return;
|
||
}
|
||
list.innerHTML = filtered.map((idea) => {
|
||
const realIdx = IDEAS.indexOf(idea);
|
||
return `<div class="idea-card" draggable="true"
|
||
ondragstart="onIdeaDragStart(event,${realIdx})"
|
||
ondragend="onIdeaDragEnd(event)"
|
||
title="Auf eine Spalte ziehen um als Task hinzuzufügen">
|
||
<div class="idea-actions">
|
||
<button class="idea-btn idea-btn-promote" onclick="openPromote(${realIdx})" title="Als Task hinzufügen">→</button>
|
||
<button class="idea-btn idea-btn-del" onclick="deleteIdea(${realIdx})" title="Löschen">✕</button>
|
||
</div>
|
||
<div class="idea-text">${idea.text}</div>
|
||
<div class="idea-meta">
|
||
<span class="idea-tag ${tagCls[idea.tag]||'it-projekt'}">${tagLbl[idea.tag]||'Projekt'}</span>
|
||
<span class="idea-date">${idea.created||''}</span>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
// ── INIT ──────────────────────────────────────────────────────────────────
|
||
if (window.location.protocol === 'file:') {
|
||
document.getElementById('proto-banner').style.display = 'flex';
|
||
}
|
||
loadGroups();
|
||
loadBoardOrder();
|
||
loadState();
|
||
renderSidebar();
|
||
loadIdeas();
|
||
renderIdeas();
|
||
show('doener');
|
||
setView('overview');
|
||
</script>
|
||
</body>
|
||
</html>
|