- 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>
1933 lines
104 KiB
HTML
1933 lines
104 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Robin — Projects</title>
|
||
<style>
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
:root {
|
||
--bg:#0d0d0f; --surface:#16161a; --surface2:#1e1e24; --surface3:#26262e;
|
||
--border:#2a2a35; --border2:#353545;
|
||
--text:#e8e8f0; --text-muted:#6b6b80; --text-dim:#4a4a5a;
|
||
--accent:#7c6af7; --accent-soft:#2d2550;
|
||
--green:#4ade80; --green-soft:#0f2e1a;
|
||
--amber:#fbbf24; --amber-soft:#2d2000;
|
||
--red:#f87171; --red-soft:#2d0f0f;
|
||
--blue:#60a5fa; --blue-soft:#0f1e2d;
|
||
--teal:#2dd4bf; --teal-soft:#0a2420;
|
||
--purple:#c084fc; --purple-soft:#1e0f2d;
|
||
--orange:#fb923c; --orange-soft:#2d1500;
|
||
--sidebar-w:210px; --col-w:240px;
|
||
}
|
||
|
||
body { background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,'Inter','Segoe UI',sans-serif; font-size:14px; height:100vh; display:flex; overflow:hidden; }
|
||
|
||
/* ── SIDEBAR ── */
|
||
.sidebar { width:var(--sidebar-w); min-width:var(--sidebar-w); background:var(--surface); border-right:1px solid var(--border); display:flex; flex-direction:column; overflow-y:auto; padding-bottom:20px; }
|
||
.s-head { padding:16px 14px 10px; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--text-muted); position:sticky; top:0; background:var(--surface); border-bottom:1px solid var(--border); margin-bottom:6px; }
|
||
.s-group { padding:10px 14px 3px; font-size:9.5px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--text-dim); }
|
||
.nav-item { display:flex; align-items:center; gap:8px; padding:6px 14px; cursor:pointer; border-left:2px solid transparent; user-select:none; transition:background .1s; }
|
||
.nav-item:hover { background:var(--surface2); }
|
||
.nav-item.active { background:var(--accent-soft); border-left-color:var(--accent); }
|
||
.nav-item.active .nav-name { color:var(--accent); }
|
||
.nav-dot { width:7px; height:7px; border-radius:50%; flex-shrink:0; }
|
||
.nav-name { flex:1; font-size:13px; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||
.nav-badge { font-size:10px; font-weight:700; color:var(--text-dim); background:var(--surface3); padding:1px 6px; border-radius:10px; min-width:18px; text-align:center; }
|
||
.nav-badge.wip { background:var(--blue-soft); color:var(--blue); }
|
||
.nav-badge.blk { background:var(--red-soft); color:var(--red); }
|
||
.s-reset { margin:6px 14px 0; padding:5px 10px; background:var(--surface3); border:1px solid var(--border); border-radius:5px; font-size:10px; color:var(--text-dim); cursor:pointer; text-align:center; }
|
||
.s-reset:hover { color:var(--red); border-color:var(--red); }
|
||
.s-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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
function renderOverview(id) {
|
||
const b = BOARDS[id];
|
||
if (!b.overview) b.overview = {};
|
||
const ov = b.overview;
|
||
const el = document.getElementById('view-overview');
|
||
|
||
const launches = (ov.launches || []).map((L,i) => `
|
||
<div class="ov-launch-card" style="position:relative" data-url="${escapeHtml(L.url || '')}" onclick="onLaunchClick(event,this)" title="Klick zum Öffnen · Doppelklick auf Text zum Editieren">
|
||
<div class="ov-launch-icon" data-section="launches" data-idx="${i}" data-prop="icon" ondblclick="enableEdit(this)" onblur="this.contentEditable='false';updateOvFromEl(this)">${escapeHtml(L.icon || '🔗')}</div>
|
||
<div class="ov-launch-meta">
|
||
<div class="ov-launch-lbl" data-section="launches" data-idx="${i}" data-prop="label" ondblclick="enableEdit(this)" onblur="this.contentEditable='false';updateOvFromEl(this)">${escapeHtml(L.label || '')}</div>
|
||
<div class="ov-launch-sub" data-section="launches" data-idx="${i}" data-prop="sub" ondblclick="enableEdit(this)" onblur="this.contentEditable='false';updateOvFromEl(this)">${escapeHtml(L.sub || '')}</div>
|
||
<div class="ov-launch-sub" style="margin-top:2px;opacity:.55;font-size:9.5px;word-break:break-all" data-section="launches" data-idx="${i}" data-prop="url" ondblclick="enableEdit(this)" onblur="this.contentEditable='false';updateOvFromEl(this)">${escapeHtml(L.url || '')}</div>
|
||
</div>
|
||
<button class="ov-row-del" style="opacity:1;align-self:flex-start" onclick="event.stopPropagation();deleteOvRow('launches',${i})" title="Entfernen">×</button>
|
||
</div>`).join('');
|
||
|
||
const stack = (ov.stack || []).map((s,i) => `
|
||
<span class="ov-pill-wrap">
|
||
<span class="ov-pill ov-edit" data-section="stack" data-idx="${i}" data-prop="" onblur="updateOvFromEl(this)" contenteditable="true">${escapeHtml(s)}</span>
|
||
<button class="ov-pill-del" onclick="deleteOvRow('stack',${i})" title="Entfernen">×</button>
|
||
</span>`).join('')
|
||
+ `<input class="ov-pill-input" placeholder="+ Tech" onkeydown="if(event.key==='Enter'&&this.value.trim()){addOvPill('stack',this.value.trim());this.value=''}">`;
|
||
|
||
const infoRows = (ov.info || []).map((it,i) => `
|
||
<div class="ov-info-row">
|
||
<div class="ov-info-lbl ov-edit" data-section="info" data-idx="${i}" data-prop="label" onblur="updateOvFromEl(this)" contenteditable="true" data-placeholder="Label">${escapeHtml(it.label || '')}</div>
|
||
<div class="ov-info-val ov-edit" data-section="info" data-idx="${i}" data-prop="value" onblur="updateOvFromEl(this)" contenteditable="true" data-placeholder="Wert">${escapeHtml(it.value || '')}</div>
|
||
<button class="ov-icon-btn" data-copy="${escapeHtml(it.value || '')}" onclick="copyFromBtn(this)" title="Kopieren">⧉</button>
|
||
<button class="ov-row-del" onclick="deleteOvRow('info',${i})" title="Entfernen">×</button>
|
||
</div>`).join('');
|
||
|
||
const secrets = (ov.secrets || []).map((s,i) => `
|
||
<div class="ov-info-row">
|
||
<div class="ov-info-lbl ov-edit" data-section="secrets" data-idx="${i}" data-prop="label" onblur="updateOvFromEl(this)" contenteditable="true" data-placeholder="Label">${escapeHtml(s.label || '')}</div>
|
||
<div class="ov-info-val ${s.masked?'masked':''} ov-edit" id="ov-sec-${i}" data-value="${escapeHtml(s.value || '')}" data-section="secrets" data-idx="${i}" data-prop="value" onblur="updateOvFromEl(this)" contenteditable="true" data-placeholder="Wert">${s.masked ? '••••••••••••••••' : escapeHtml(s.value || '')}</div>
|
||
<button class="ov-icon-btn" data-idx="${i}" onclick="toggleSecretBtn(this)" title="${s.masked?'Anzeigen':'Maskieren'}">${s.masked?'👁':'🙈'}</button>
|
||
<button class="ov-icon-btn" data-copy="${escapeHtml(s.value || '')}" onclick="copyFromBtn(this)" title="Kopieren">⧉</button>
|
||
<button class="ov-row-del" onclick="deleteOvRow('secrets',${i})" title="Entfernen">×</button>
|
||
</div>`).join('');
|
||
|
||
const commands = (ov.commands || []).map((c,i) => `
|
||
<div style="margin-bottom:10px">
|
||
<div class="ov-edit" style="font-size:10.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--text-muted);margin-bottom:4px" data-section="commands" data-idx="${i}" data-prop="label" onblur="updateOvFromEl(this)" contenteditable="true" data-placeholder="Label">${escapeHtml(c.label || '')}</div>
|
||
<div class="ov-cmd">
|
||
<div class="ov-cmd-text ov-edit" data-section="commands" data-idx="${i}" data-prop="cmd" onblur="updateOvFromEl(this)" contenteditable="true" data-placeholder="curl ...">${escapeHtml(c.cmd || '')}</div>
|
||
<button class="ov-icon-btn" data-copy="${escapeHtml(c.cmd || '')}" onclick="copyFromBtn(this)" title="Kopieren">⧉</button>
|
||
<button class="ov-row-del" onclick="deleteOvRow('commands',${i})" title="Entfernen">×</button>
|
||
</div>
|
||
</div>`).join('');
|
||
|
||
const ringLbl = RING_LABELS[b.ring || '?'] || '?';
|
||
const groupBadge = b.group ? `<span class="ov-hero-badge">${escapeHtml(b.group)}</span>` : '';
|
||
const ringBadge = `<span class="ov-hero-badge nav-ring r-${b.ring==='?'?'q':(b.ring||'q')}" style="cursor:default">${ringLbl}</span>`;
|
||
const typeBadge = ov.type ? `<span class="ov-hero-badge ov-edit" data-section="type" data-idx="" data-prop="" onblur="updateOvFromEl(this)" contenteditable="true">${escapeHtml(ov.type)}</span>` : '';
|
||
|
||
const heroIcon = ov.icon
|
||
? `<img class="ov-hero-icon" src="${escapeHtml(ov.icon)}" alt="${escapeHtml(b.name)} Icon" onerror="this.outerHTML='<div class=ov-hero-icon-fb style=background:${escapeHtml(b.color)}33;color:${escapeHtml(b.color)}>📋</div>'">`
|
||
: `<div class="ov-hero-icon-fb" style="background:${escapeHtml(b.color)}33;color:${escapeHtml(b.color)}">📋</div>`;
|
||
|
||
el.innerHTML = `
|
||
<div class="ov-hero">
|
||
${heroIcon}
|
||
<div class="ov-hero-body">
|
||
<div class="ov-hero-name">${escapeHtml(b.name)}</div>
|
||
<div class="ov-hero-tag ov-edit" data-section="tagline" data-idx="" data-prop="" onblur="updateOvFromEl(this)" contenteditable="true" data-placeholder="Tagline — eine kurze Beschreibung in einem Satz">${escapeHtml(ov.tagline || '')}</div>
|
||
<div class="ov-hero-desc ov-edit" data-section="description" data-idx="" data-prop="" onblur="updateOvFromEl(this)" contenteditable="true" data-placeholder="Längere Beschreibung — 2-3 Sätze, was genau gemacht wird">${escapeHtml(ov.description || '')}</div>
|
||
<div class="ov-hero-badges">${ringBadge}${groupBadge}${typeBadge || `<span class="ov-hero-badge ov-edit" data-section="type" data-idx="" data-prop="" onblur="updateOvFromEl(this)" contenteditable="true" data-placeholder="+ Typ">+ Typ</span>`}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ov-launch">${launches}</div>
|
||
<button class="ov-add-row" style="margin-bottom:18px" onclick="addOvRow('launches')">+ Launch-Card</button>
|
||
|
||
<div class="ov-row two">
|
||
<div class="ov-panel">
|
||
<div class="ov-title">Was bisher geschah</div>
|
||
<div class="ov-summary ov-edit" data-section="summary" data-idx="" data-prop="" onblur="updateOvFromEl(this)" contenteditable="true" data-placeholder="Status, Meilensteine, was läuft, was hängt — der lebendige Stand">${escapeHtml(ov.summary || '')}</div>
|
||
</div>
|
||
<div class="ov-panel">
|
||
<div class="ov-title">Tech-Stack</div>
|
||
<div class="ov-stack">${stack}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ov-row full">
|
||
<div class="ov-panel">
|
||
<div class="ov-title">Hosting · IDs · Pfade</div>
|
||
${infoRows}
|
||
<button class="ov-add-row" onclick="addOvRow('info')">+ Zeile</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ov-row full">
|
||
<div class="ov-panel">
|
||
<div class="ov-title">🔒 Secrets · Keys</div>
|
||
${secrets}
|
||
<button class="ov-add-row" onclick="addOvRow('secrets')">+ Secret</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ov-row full">
|
||
<div class="ov-panel">
|
||
<div class="ov-title">⌘ Quick-Commands</div>
|
||
${commands}
|
||
<button class="ov-add-row" onclick="addOvRow('commands')">+ Command</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function copyFromBtn(btn) {
|
||
copyText(btn.dataset.copy || '', btn);
|
||
}
|
||
|
||
function copyText(t, btn) {
|
||
navigator.clipboard.writeText(t).then(() => {
|
||
if (!btn) return;
|
||
const orig = btn.textContent;
|
||
btn.textContent = '✓';
|
||
btn.classList.add('copied');
|
||
setTimeout(() => { btn.textContent = orig; btn.classList.remove('copied'); }, 1200);
|
||
});
|
||
}
|
||
|
||
function toggleSecretBtn(btn) {
|
||
const idx = btn.dataset.idx;
|
||
const el = document.getElementById('ov-sec-' + idx);
|
||
if (!el) return;
|
||
const ov = BOARDS[curId].overview;
|
||
if (!ov || !ov.secrets || !ov.secrets[idx]) return;
|
||
ov.secrets[idx].masked = !ov.secrets[idx].masked;
|
||
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(/^> (.+)$/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>
|