Files
kanban/index.html
Robin Choice 4f5e16a286 feat: replace localStorage with Bun/Hono API backend
- server.ts: Hono server with basic auth, GET/PUT/DELETE /api/* endpoints
- defaults.json: extracted board defaults from index.html
- Dockerfile: containerized for Coolify deployment
- index.html: all state-layer rewritten from localStorage to fetch API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 19:13:13 +02:00

1933 lines
104 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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-io { display:flex; gap:6px; margin:6px 14px 0; }
.s-io-btn { flex:1; text-align:center; padding:5px 0; background:var(--surface3); border:1px solid var(--border); border-radius:5px; font-size:10px; color:var(--text-dim); cursor:pointer; }
.s-io-btn:hover { color:var(--teal); border-color:var(--teal); }
.s-io-btn.import:hover { color:var(--amber); border-color:var(--amber); }
.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; }
.confirm-box { background:var(--surface); border:1px solid var(--border2); border-radius:10px; padding:20px; width:260px; box-shadow:0 8px 32px rgba(0,0,0,.5); }
.confirm-msg { font-size:13px; color:var(--text); margin-bottom:14px; }
.confirm-ok { flex:1; padding:7px; background:var(--red); color:#fff; border:none; border-radius:5px; font-size:12px; font-weight:700; cursor:pointer; }
.confirm-ok:hover { opacity:.85; }
</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>
</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>
<div id="confirm-overlay" style="display:none" class="promote-overlay" onclick="if(event.target===this)closeConfirm()">
<div class="confirm-box">
<div class="confirm-msg" id="confirm-msg"></div>
<div class="promote-btns">
<button class="confirm-ok" id="confirm-ok">Löschen</button>
<button class="promote-cancel" onclick="closeConfirm()">Abbrechen</button>
</div>
</div>
</div>
<script>
// ── API STATE ────────────────────────────────────────────────────────────────
async function fetchState() {
const data = await (await fetch('/api/state')).json();
GROUPS = data.meta.groups || ['Meta','Code','Beruflich','Web','Privat','Musik'];
BOARD_ORDER = data.meta.boardOrder || {};
BOARDS = data.boards || {};
IDEAS = data.ideas || [];
if (IDEAS.length === 0) { IDEAS = [...SEED_IDEAS]; saveIdeas(); }
}
function saveBoard(id) {
if (!BOARDS[id]) return;
fetch('/api/boards/' + id, { method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(BOARDS[id]) });
}
function saveMeta() {
fetch('/api/meta', { method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ groups:GROUPS, boardOrder:BOARD_ORDER }) });
}
function saveIdeas() {
fetch('/api/ideas', { method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ ideas:IDEAS }) });
}
const TODAY = new Date();
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 = [];
let 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 ─────────────────────────────────────────────────────────────────
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 saveFocus() {
const el = document.getElementById('focus-text');
if (!el) return;
BOARDS[curId].focus = el.textContent.trim();
saveBoard(curId);
}
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;
saveBoard(curId);
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;
saveBoard(curId);
}
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;
}
saveBoard(curId);
show(curId);
if (curView === 'analytics') renderAnalytics(curId);
}
function showConfirm(msg, onOk) {
document.getElementById('confirm-msg').textContent = msg;
document.getElementById('confirm-ok').onclick = () => { closeConfirm(); onOk(); };
document.getElementById('confirm-overlay').style.display = 'flex';
}
function closeConfirm() { document.getElementById('confirm-overlay').style.display = 'none'; }
function resetCurrentBoard() { deleteBoard(curId); }
function deleteBoard(id, e) {
if (e) e.stopPropagation();
showConfirm(`"${BOARDS[id]?.name}" wirklich löschen?`, () => {
fetch('/api/boards/' + id, { method:'DELETE' });
delete BOARDS[id];
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);
saveBoard(boardId);
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);
saveBoard(boardId);
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];
saveBoard(boardId);
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); saveMeta(); }
}
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); saveBoard(promoteSource.boardId); }
promoteSource = null;
}
saveBoard(id);
saveMeta();
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); saveMeta(); 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);
saveMeta();
saveBoard(dragBoard);
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);
saveBoard(dragBoard);
}
saveMeta();
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);
saveMeta();
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);
saveBoard(boardId);
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});
saveBoard(boardId);
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; saveBoard(boardId); 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;
saveBoard(boardId);
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;
saveBoard(boardId);
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; saveBoard(boardId); 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;
saveBoard(boardId);
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;
saveBoard(curId);
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;
}
saveBoard(curId);
}
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);
saveBoard(curId);
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);
saveBoard(curId);
renderOverview(curId);
}
function deleteOvRow(section, idx) {
const ov = BOARDS[curId].overview;
if (!ov || !ov[section]) return;
ov[section].splice(idx, 1);
saveBoard(curId);
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 = `<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'},
];
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 }); saveBoard(boardId); 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 ──────────────────────────────────────────────────────────────────
(async () => {
await fetchState();
renderSidebar();
renderIdeas();
show('doener');
setView('overview');
})();
</script>
</body>
</html>