Files
kanban/index.html
Robin Choice 02ac470eff Initial commit — Kanban-App migriert von ~/.kanban nach ~/dev/kanban
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>
2026-05-19 23:30:19 +02:00

2332 lines
130 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 03.',
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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(/^&gt; (.+)$/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>