feat: replace localStorage with Bun/Hono API backend
- server.ts: Hono server with basic auth, GET/PUT/DELETE /api/* endpoints - defaults.json: extracted board defaults from index.html - Dockerfile: containerized for Coolify deployment - index.html: all state-layer rewritten from localStorage to fetch API Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -2,4 +2,9 @@ node_modules/
|
||||
.DS_Store
|
||||
*.log
|
||||
backup-*.html
|
||||
bun.lock
|
||||
extract-defaults.mjs
|
||||
transform.mjs
|
||||
index.html.bak
|
||||
data/
|
||||
|
||||
|
||||
8
Dockerfile
Normal file
8
Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM oven/bun:1
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN bun install --production
|
||||
COPY server.ts bootstrap.ts defaults.json index.html ./
|
||||
COPY assets/ ./assets/
|
||||
EXPOSE 3000
|
||||
CMD ["bun", "run", "server.ts"]
|
||||
1520
defaults.json
Normal file
1520
defaults.json
Normal file
File diff suppressed because it is too large
Load Diff
649
index.html
649
index.html
@@ -400,12 +400,7 @@ body { background:var(--bg); color:var(--text); font-family:-apple-system,BlinkM
|
||||
<div class="s-add-btn" onclick="showAddBoard()">+ Board</div>
|
||||
<div class="s-add-btn" onclick="showAddGroup()">+ Gruppe</div>
|
||||
</div>
|
||||
<div class="s-reset" onclick="resetCurrentBoard()">↺ Reset</div>
|
||||
<div class="s-io">
|
||||
<div class="s-io-btn" onclick="exportState()" title="Kompletten State als JSON exportieren">↓ Export</div>
|
||||
<div class="s-io-btn import" onclick="document.getElementById('import-file').click()" title="State aus JSON-Datei laden">↑ Import</div>
|
||||
</div>
|
||||
<input type="file" id="import-file" accept=".json" style="display:none" onchange="importState(this)">
|
||||
|
||||
</nav>
|
||||
|
||||
<div class="main">
|
||||
@@ -529,435 +524,37 @@ body { background:var(--bg); color:var(--text); font-family:-apple-system,BlinkM
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── BOARD DATA ────────────────────────────────────────────────────────────
|
||||
const TODAY = new Date('2026-05-19');
|
||||
// ── 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(); }
|
||||
}
|
||||
|
||||
const DEFAULTS = {
|
||||
ringsystem: {
|
||||
name:'Ringsystem', goal:'Tool-agnostische Architektur — AGENTS.md kanonisch, CLAUDE.md/Codex/OpenCode als Symlinks. ARCHITECTURE.md definiert Ring 0–3.',
|
||||
wipLimit:2, throughput:1, sle:{days:30,p:85},
|
||||
overview: {
|
||||
tagline:'Privacy-Schichten von innen (alles privat) nach außen (öffentlich).',
|
||||
description:'Ring 0 (personal-vault) sieht alles und routet. Ring 1 trennt privat (robin-private) von beruflich (robin-work). Ring 2 = Sub-Projekte in Ring 1. Ring 3 = öffentliche Repos (doener-app, musichub, pleasance, …). Sichtbarkeit von innen nach außen — Referenzen NUR von außen nach innen.',
|
||||
summary:'Tool-Agnostik durchgesetzt: AGENTS.md ist die kanonische Datei, jedes andere Tool greift via Symlink darauf zu (CLAUDE.md → AGENTS.md, gleiche Konvention für Codex, OpenCode). Skill-Files werden über ~/.skills/{name}.md zentral geteilt. Aktuell: globale ~/.claude/CLAUDE.md auf Symlink-Modell migriert.',
|
||||
type:'meta',
|
||||
launches:[
|
||||
{label:'ARCHITECTURE.md', sub:'Ring-Topologie + Dispatch-Konvention', url:'http://localhost:8765/dev/personal-vault/ARCHITECTURE.md', icon:'🔄'},
|
||||
{label:'Global AGENTS.md', sub:'~/.claude/AGENTS.md (= CLAUDE.md)', url:'http://localhost:8765/.claude/AGENTS.md', icon:'⚙'},
|
||||
{label:'Ring 0 AGENTS.md', sub:'personal-vault', url:'http://localhost:8765/dev/personal-vault/AGENTS.md', icon:'⊙'},
|
||||
{label:'Ring 1w AGENTS.md', sub:'robin-work', url:'http://localhost:8765/dev/robin-work/AGENTS.md', icon:'●'},
|
||||
{label:'Ring 1p AGENTS.md', sub:'robin-private', url:'http://localhost:8765/dev/robin-private/AGENTS.md', icon:'○'},
|
||||
{label:'Skills-Verzeichnis', sub:'~/.claude/skills/ (alle Skills)', url:'http://localhost:8765/.claude/skills/', icon:'🛠'},
|
||||
{label:'Rules-Verzeichnis', sub:'~/.claude/rules/ (Verhalten)', url:'http://localhost:8765/.claude/rules/', icon:'📐'},
|
||||
{label:'Kanban SPEC.md', sub:'~/dev/kanban/SPEC.md', url:'http://localhost:8765/dev/kanban/SPEC.md', icon:'📖'},
|
||||
],
|
||||
stack:['AGENTS.md (kanonisch)','CLAUDE.md (Symlink)','Codex/OpenCode (Symlink)','Skills via ~/.skills/','Dispatch via ~/.agent-signals/','Ring-Architektur'],
|
||||
info:[
|
||||
{label:'Naming-Konvention', value:'AGENTS.md ist Quelle; Tool-spezifische Files sind Symlinks darauf'},
|
||||
{label:'Ring 0', value:'~/dev/personal-vault/ — Vault, sieht alles, niemand sieht hier rein'},
|
||||
{label:'Ring 1 privat', value:'~/dev/robin-private/ — Privat-Workspace + Ring-2-Projekte'},
|
||||
{label:'Ring 1 beruflich', value:'~/dev/robin-work/ — Business-Workspace + Ring-2-Projekte'},
|
||||
{label:'Ring 2', value:'Sub-Projekte in Ring 1 — z.B. ~/dev/robin-work/projects/{name}/'},
|
||||
{label:'Ring 3', value:'Öffentliche Repos in ~/dev/ direkt — doener-app, musichub, pleasance, mdim, openclaw, docpilot, ai-engineering'},
|
||||
{label:'Skills', value:'~/.claude/skills/{name}/SKILL.md → Symlink auf ~/.skills/{name}.md (tool-agnostisch teilbar)'},
|
||||
{label:'Dispatch zwischen Ringen', value:'~/.agent-signals/dispatch/{workspace}.dispatch.md'},
|
||||
{label:'Sichtbarkeitsregel', value:'innen → außen: Ring 0 sieht alles, Ring 1 sieht Ring 2+3, Ring 2 sieht Ring 3'},
|
||||
{label:'Referenz-Regel', value:'außen → innen: nur wissen DASS innere Ringe existieren, NIEMALS Inhalte lesen oder leaken'},
|
||||
],
|
||||
commands:[
|
||||
{label:'Globale AGENTS lesen', cmd:'cat ~/.claude/AGENTS.md'},
|
||||
{label:'ARCHITECTURE lesen', cmd:'cat ~/dev/personal-vault/ARCHITECTURE.md'},
|
||||
{label:'Alle Skills auflisten', cmd:'ls ~/.claude/skills/'},
|
||||
{label:'Symlink-Check', cmd:'find ~/dev/robin-work ~/dev/robin-private ~/dev/personal-vault -maxdepth 2 -name "CLAUDE.md" -type l'},
|
||||
],
|
||||
},
|
||||
cols:[
|
||||
{id:'ready', label:'Ready', tasks:[
|
||||
{t:'AGENTS.md ## Aktueller Stand in allen Code-Repos auf 2-Bullet-Snapshot schrumpfen'},
|
||||
{t:'Codex/OpenCode Symlink-Setup verifizieren oder anlegen'},
|
||||
{t:'~/.skills/ als kanonischen Skill-Store dokumentieren'},
|
||||
{t:'Ring-Mapping-Tabelle in ARCHITECTURE.md aktuell halten (cmux-toolkit, khala etc.)'},
|
||||
]},
|
||||
{id:'wip', label:'In Progress', tasks:[]},
|
||||
{id:'done', label:'Done', tasks:[
|
||||
{t:'~/.claude/CLAUDE.md → AGENTS.md migriert (Symlink-Konvention)',doneAt:1779580800000},
|
||||
{t:'Ringsystem-Meta-Board angelegt mit Overview aller relevanten MD-Files',doneAt:1779580800000},
|
||||
]},
|
||||
],
|
||||
},
|
||||
kanban: {
|
||||
name:'Kanban (dieses Board)', goal:'Self-contained HTML-Kanban — ~/dev/kanban/index.html. Visualisierung des Ringsystems, Single Source of Truth für laufende Projekte.',
|
||||
wipLimit:2, throughput:3, sle:{days:7,p:85},
|
||||
overview: {
|
||||
tagline:'Autopoiesis — das System, das sich selbst beobachtet und steuert.',
|
||||
description:'Single Source of Truth für alle laufenden Projekte. Visualisiert das Ringsystem (Ring 0 → Ring 3) als 5-Dimensionen-Layer: Gruppe (Domäne) × Ring (Privacy) × Board (Projekt) × Spalte (Flow-Status) × Karten-Class-of-Service. Self-contained HTML/CSS/JS, keine Build-Pipeline, kein Backend. State in localStorage. Anlehnung an Luhmanns Systemtheorie: das Kanban beobachtet sich selbst (dieses Meta-Board) und alle anderen Projekte gleichzeitig.',
|
||||
summary:'Reifegrad: produktionsreif für tägliche Nutzung. Aktuell in Iteration: Overview-Pages pro Projekt mit Hero-Icons, Quick-Launch, Secrets, Quick-Commands. Pilot war Döner-App. Ausstehend: Overview-Daten für die restlichen Boards.',
|
||||
type:'meta',
|
||||
launches:[
|
||||
{label:'Kanban öffnen', sub:'~/dev/kanban/index.html', url:'http://localhost:8765/dev/kanban/index.html', icon:'📋'},
|
||||
{label:'Spec lesen', sub:'SPEC.md (Architektur + Test-Checkliste)', url:'http://localhost:8765/dev/kanban/SPEC.md', icon:'📖'},
|
||||
{label:'ARCHITECTURE.md', sub:'Ring-System kanonisch', url:'http://localhost:8765/dev/personal-vault/ARCHITECTURE.md', icon:'🔄'},
|
||||
{label:'CLAUDE.md global', sub:'Skill-Routing + Globals', url:'http://localhost:8765/.claude/CLAUDE.md', icon:'🤖'},
|
||||
],
|
||||
stack:['HTML5','CSS3 (Custom Properties)','Vanilla JS','HTML5 Drag & Drop API','localStorage','Monte-Carlo-Simulation','Little\'s Law','Luhmann-Autopoiesis'],
|
||||
info:[
|
||||
{label:'Datei', value:'/Users/robinchoice/dev/kanban/index.html'},
|
||||
{label:'Spec', value:'/Users/robinchoice/dev/kanban/SPEC.md'},
|
||||
{label:'Assets', value:'/Users/robinchoice/dev/kanban/assets/ (App-Icons)'},
|
||||
{label:'localStorage', value:'kanban_v2 · kanban_groups · kanban_board_order · kanban_ideas · kanban_ideas_seeded'},
|
||||
{label:'Tabs', value:'Overview (default) · Board · Analytics'},
|
||||
{label:'Gruppen', value:'Meta · Code · Beruflich · Web · Privat'},
|
||||
{label:'Ringe', value:'R0 (Meta) · R1p (privat) · R1w (beruflich) · R2 (Sub-Projekt) · R3 (öffentlich)'},
|
||||
{label:'Seed-Version', value:'v3 — 16 neue Ideen aus _archive (Songideen, Bitcoin Bank, Cypherpunk, …)'},
|
||||
],
|
||||
commands:[
|
||||
{label:'Kanban öffnen', cmd:'open ~/dev/kanban/index.html'},
|
||||
{label:'Spec ansehen', cmd:'cat ~/dev/kanban/SPEC.md'},
|
||||
{label:'localStorage komplett resetten', cmd:'echo \'localStorage.clear()\' // im Browser-Devtools-Console ausführen'},
|
||||
{label:'Backup-Dump', cmd:'cp ~/dev/kanban/index.html ~/dev/kanban/backup-$(date +%Y%m%d).html'},
|
||||
],
|
||||
},
|
||||
cols:[
|
||||
{id:'ready', label:'Ready', tasks:[
|
||||
{t:'CFD (Cumulative Flow Diagram) via tägliche Snapshots'},
|
||||
{t:'Definition of Ready / Done pro Spalte editierbar'},
|
||||
{t:'Class-of-Service-Mix-Chart in Analytics'},
|
||||
{t:'PWA-Manifest für iPad Home-Screen (Stufe 1)'},
|
||||
{t:'Sync-Layer (Cloudflare Worker + KV) für Multi-Device (Stufe 3)'},
|
||||
{t:'AGENTS.md ## Aktueller Stand in allen Code-Repos auf 2-Bullet-Snapshot schrumpfen'},
|
||||
{t:'Filesystem-Sync (optional): Node-Backend Boards ↔ Ordner'},
|
||||
{t:'Card-Edit (Titel/Note direkt in der Karte)'},
|
||||
{t:'Sortierung INNERHALB einer Spalte per Drag'},
|
||||
{t:'Export / Backup als JSON-Dump'},
|
||||
{t:'Overview-Daten für alle restlichen Boards (musichub, openclaw, docpilot, k4, pleasance, mdim, …)'},
|
||||
]},
|
||||
{id:'wip', label:'In Progress', tasks:[
|
||||
{t:'Robin testet das Board auf Herz & Nieren'},
|
||||
]},
|
||||
{id:'done', label:'Done', tasks:[
|
||||
{t:'WIP-Limit pro Board editierbar (Header-Pill)',doneAt:1779580800000},
|
||||
{t:'Launch-Cards komplett klickbar (nicht nur ↗)',doneAt:1779577200000},
|
||||
{t:'Overview komplett editierbar (alle Felder, Add/Remove Rows)',doneAt:1779573600000},
|
||||
{t:'Auto-Aging — movedAt-Timestamp + Glow für überfällige Karten',doneAt:1779570000000},
|
||||
{t:'Throughput-History — doneAt-Timestamp + reale 14-Tage-Berechnung',doneAt:1779566400000},
|
||||
{t:'Flow Efficiency Berechnung korrigiert (WIP/(WIP+Ready))',doneAt:1779562800000},
|
||||
{t:'Board-Name + Goal per Klick editierbar',doneAt:1779559200000},
|
||||
{t:'kanban-mcp aus MCP-Config entfernt',doneAt:1779555600000},
|
||||
{t:'_archive READMEs als DEPRECATED-Marker angelegt',doneAt:1779552000000},
|
||||
{t:'Overview-Hero mit App-Icon + Tagline + Description',doneAt:1779494400000},
|
||||
{t:'Tab-Reihenfolge: Overview · Board · Analytics',doneAt:1779494400000},
|
||||
{t:'Overview-Pilot Döner-App (Quick-Launch, Hosting, Secrets, Commands)',doneAt:1779408000000},
|
||||
{t:'Ring-Badge an Boards + Klick zum Wechseln',note:'R0/R1p/R1w/R2/R3 — Sidebar-Pill',doneAt:1779321600000},
|
||||
{t:'Gruppen-Reihenfolge per Drag ändern',doneAt:1779321600000},
|
||||
{t:'Task → Idee per Drag (Tag-Drop-Zone)',doneAt:1779321600000},
|
||||
{t:'Meta-Gruppe + dieses Kanban-Board angelegt',doneAt:1779235200000},
|
||||
{t:'SPEC.md komplett neu (Test-Checkliste für Tester-Agent)',doneAt:1779235200000},
|
||||
{t:'Promote-Card-zu-Board (↗ Button)',doneAt:1779148800000},
|
||||
{t:'Tiefenscan _archive → 16 neue Seed-Ideen v3',doneAt:1779148800000},
|
||||
{t:'Card-Delete + Promote-Modal-Bug + Group-Create-Bug gefixt',doneAt:1779062400000},
|
||||
{t:'Idee-DnD → Spalte + Board-Drag zwischen Gruppen + Board-Reorder',doneAt:1779062400000},
|
||||
{t:'3-Spalten-Vereinfachung + Blocker-Flag statt Tags',doneAt:1778976000000},
|
||||
{t:'Expedite-Swimlane (3 Zonen aligned zu Spalten)',doneAt:1778976000000},
|
||||
{t:'Add-Card-Input + Add-Board-Modal + Add-Group',doneAt:1778889600000},
|
||||
{t:'Ideen-Pinnwand rechts + 35 Seed-Ideen aus Ring-System',doneAt:1778889600000},
|
||||
{t:'Monte-Carlo-Forecast + Kanban-KPIs (Cycle Time, Flow Efficiency, SLE)',doneAt:1778803200000},
|
||||
{t:'localStorage-Persistierung + 5→3-Spalten-Migration',doneAt:1778803200000},
|
||||
{t:'Initial-Setup: 14 Boards aus Robins Ring-System ingested',doneAt:1778716800000},
|
||||
]},
|
||||
],
|
||||
},
|
||||
doener: {
|
||||
name:'Döner-App', goal:'iOS-App in Swift 6 + SwiftUI + MapKit. Ziel: tester-ready, dann App Store Launch.',
|
||||
wipLimit:3, throughput:2, sle:{days:14,p:85},
|
||||
overview: {
|
||||
icon:'assets/doener.png',
|
||||
tagline:'Find. Bewerte. Sammle. — eine Stempelkarte für Dönerläden.',
|
||||
description:'iOS-App zum Finden, Bewerten und Sammeln von Dönerläden. Offline-First mit Community-Layer: Check-Ins, Sauce/Fleisch/Brot-Ratings, persönliche Döner-Geschichte und Live-Status von Freunden. Karte nutzt OpenStreetMap via Overpass-API mit SwiftData-Caching (24h-Refresh). Gamification-Mechaniken angelehnt an Pokémon Go und Spotify Wrapped.',
|
||||
summary:'Sprint 3 — Launch-Readiness. Backend läuft auf Coolify (Hetzner). iOS-App vor dem Switch von LAN auf Prod-Backend. Sprints 1+2 fertig (Tester-Readiness + Backend-Sync + Freunde-Feed live). Aktuell: S3.2 Produktiv-Hosting in WIP, S3.8 Apple JWKS-Validierung als Security-Blocker.',
|
||||
type:'ios-app',
|
||||
launches:[
|
||||
{label:'GitHub Repo', sub:'robinchoice/DoenerApp', url:'https://github.com/robinchoice/DoenerApp', icon:'⌥'},
|
||||
{label:'Coolify Dashboard', sub:'App jdna5c4…', url:'https://coolify.diespaetzles.lol', icon:'🚀'},
|
||||
{label:'App Store Connect', sub:'Beta noch nicht released', url:'https://appstoreconnect.apple.com', icon:'📱'},
|
||||
{label:'Apple Developer', sub:'Approval ausstehend', url:'https://developer.apple.com/account', icon:'⚙'},
|
||||
],
|
||||
stack:['Swift 6','SwiftUI','MapKit','SwiftData','Vapor','PostgreSQL + PostGIS','Docker','Coolify','Hetzner','Overpass API'],
|
||||
info:[
|
||||
{label:'Repo-Pfad', value:'~/dev/doener-app'},
|
||||
{label:'Backend-Stack', value:'Vapor (Swift) + PostgreSQL + PostGIS'},
|
||||
{label:'Coolify App-UUID', value:'jdna5c4aqx6bf6u10bs5j48n'},
|
||||
{label:'Coolify DB-UUID', value:'mzu4msj785xpe5nl6ypntb4d'},
|
||||
{label:'Coolify-Instanz', value:'coolify.diespaetzles.lol'},
|
||||
{label:'iOS APIConfig', value:'iOS/DoenerApp/.../APIConfig.swift (Switch LAN→Prod ausstehend)'},
|
||||
{label:'Hosting', value:'Hetzner VPS + Coolify, deployed via git.diespaetzles.lol'},
|
||||
{label:'Apple JWKS', value:'S3.8 — Signaturprüfung implementieren (SECURITY-BLOCKER)'},
|
||||
],
|
||||
secrets:[
|
||||
{label:'COOLIFY_SPAETZLES_TOKEN', value:'(bitte aus ~/.secrets eintragen oder env-ref lassen)', masked:true},
|
||||
{label:'GitHub Secret — COOLIFY_TOKEN', value:'(bitte eintragen)', masked:true},
|
||||
{label:'GitHub Secret — COOLIFY_APP_UUID', value:'jdna5c4aqx6bf6u10bs5j48n'},
|
||||
{label:'Apple Bundle ID', value:'(bitte eintragen — z.B. com.robinchoice.DoenerApp)'},
|
||||
{label:'Apple Team ID', value:'(bitte eintragen)'},
|
||||
],
|
||||
commands:[
|
||||
{label:'Deploy triggern', cmd:'curl -X POST -H "Authorization: Bearer $COOLIFY_SPAETZLES_TOKEN" https://coolify.diespaetzles.lol/api/v1/applications/jdna5c4aqx6bf6u10bs5j48n/start'},
|
||||
{label:'Repo öffnen', cmd:'cd ~/dev/doener-app && code .'},
|
||||
{label:'Logs anschauen', cmd:'curl -H "Authorization: Bearer $COOLIFY_SPAETZLES_TOKEN" https://coolify.diespaetzles.lol/api/v1/applications/jdna5c4aqx6bf6u10bs5j48n/logs'},
|
||||
],
|
||||
},
|
||||
cols:[
|
||||
{id:'ready', label:'Ready', tasks:[
|
||||
{t:'App-Store-Launch als Sprint Goal definieren'},{t:'S4.A Pokémon-Go Sammelmechanik'},{t:'S4.B Snapchat-Heatmap'},{t:'S4.C Döner-Symbole statt Sterne'},{t:'S4.D Community-Summary mit KI'},{t:'S4.E In-App Feedback für Tester'},
|
||||
{t:'S3.5 Push Notifications'},{t:'S3.6 Freunde per Kontakte finden'},{t:'S3.7 Leaderboards'},
|
||||
{t:'S3.8 Apple JWT Signaturprüfung',blocked:true,cos:'fixed'},
|
||||
{t:'S3.3 Google Maps Places API',blocked:true,cos:'expedite'},
|
||||
]},
|
||||
{id:'wip', label:'In Progress', tasks:[{t:'S3.2 Produktiv-Hosting',blocked:true,cos:'expedite',age:30}]},
|
||||
{id:'done', label:'Done', tasks:[{t:'Sprint 1: Tester-Readiness',note:'Sprint 1 komplett ✅'},{t:'Sprint 2: Backend-Sync',note:'Sprint 2 komplett ✅'},{t:'S3.4 Freunde-Feed + Live-Status',note:'Backend + iOS fertig, Build grün'}]},
|
||||
]
|
||||
},
|
||||
musichub: {
|
||||
name:'Music Hub', goal:'Label-Kollaborations-Webapp: SvelteKit + Hono + Postgres. Phase 3: Background Sync.',
|
||||
wipLimit:3, throughput:3, sle:{days:21,p:85},
|
||||
cols:[
|
||||
{id:'ready', label:'Ready', tasks:[
|
||||
{t:'Status quo klären + neues Sprint Goal definieren'},
|
||||
{t:'Phase 3: Background Sync (IDB-Queue, SW sync-Handler)'},{t:'Onboarding-Role für Backend-Personalisierung nutzen'},{t:'DB is_public nach STEM-Tests auf privat setzen'},
|
||||
{t:'RESEND_API_KEY setzen für E-Mail-Versand',blocked:true},
|
||||
]},
|
||||
{id:'wip', label:'In Progress', tasks:[]},
|
||||
{id:'done', label:'Done', tasks:[{t:'PWA Phase 2: Push Notifications (VAPID)',note:'VAPID, push_subscriptions, SW-Handler'},{t:'Listen Analytics + Reject with Feedback',note:'IP-Hashing, sendBeacon'},{t:'SSE Real-time (EventSource, Pub/Sub)',note:'version:new / comment:new'},{t:'Onboarding Flow + Bottom Navigation Mobile',note:'3-Step Overlay, safe-area-aware'}]},
|
||||
]
|
||||
},
|
||||
openclaw: {
|
||||
name:'OpenClaw / Rob', goal:'Eigener 24/7-KI-Assistent auf Dell OptiPlex. Gemma 4, Telegram-Bridge, MCP-Tools.',
|
||||
wipLimit:2, throughput:1, sle:{days:30,p:80},
|
||||
cols:[
|
||||
{id:'ready', label:'Ready', tasks:[
|
||||
{t:'Whisper Voice-Messages Container stabilisieren'},{t:'TELEGRAM_CHAT_ID setzen'},
|
||||
{t:'Modellwechsel + openclaw-upgrade',blocked:true,cos:'expedite'},
|
||||
]},
|
||||
{id:'wip', label:'In Progress', tasks:[]},
|
||||
{id:'done', label:'Done', tasks:[]},
|
||||
]
|
||||
},
|
||||
docpilot: {
|
||||
name:'docpilot', goal:'Git-getriggerte Doku-Updates via Claude API. v1: README-Modus → PR.',
|
||||
wipLimit:2, throughput:2, sle:{days:14,p:85},
|
||||
cols:[
|
||||
{id:'ready', label:'Ready', tasks:[
|
||||
{t:'Spec schreiben: v1 README-Modus'},
|
||||
{t:'Repo anlegen + CLAUDE.md einrichten'},{t:'GitHub Action: Diff → Claude → PR'},{t:'Erster Live-Test: Perpetual Traveler Repo'},
|
||||
]},
|
||||
{id:'wip', label:'In Progress', tasks:[]},
|
||||
{id:'done', label:'Done', tasks:[]},
|
||||
]
|
||||
},
|
||||
k4: {
|
||||
name:'K4 Digital — Ronal Edge Rollout', goal:'Freelance PM: OT-Security-Rollout für Ronal Group. 7 Werke, 326 EdgeFire + 20 EdgeIPS (TXOne Networks).',
|
||||
wipLimit:3, throughput:2, sle:{days:7,p:90},
|
||||
cols:[
|
||||
{id:'ready', label:'Ready', tasks:[
|
||||
{t:'Ticketsystem: Passwort ändern nach erstem Login'},
|
||||
{t:'Eskalationsprozess definieren (Mail + Ticketsystem) → Timo/Günter'},
|
||||
{t:'Contact List für EdgeOne-Zugang → Luis'},
|
||||
{t:'Syslog-Problem: Detail-Call mit Luis + Ronny'},
|
||||
{t:'Remote Access freigeben (Benjamin Veit)'},
|
||||
{t:'Spain-Site: Baseline-Dokumentation erstellen (neue Template-Site)'},
|
||||
{t:'Solcomp on-site Mexico — 2026-05-21',cos:'fixed'},
|
||||
{t:'Solcomp on-site Mexico — 2026-05-28',cos:'fixed'},
|
||||
]},
|
||||
{id:'wip', label:'In Progress', tasks:[]},
|
||||
{id:'done', label:'Done', tasks:[
|
||||
{t:'NDA unterzeichnet'},
|
||||
{t:'Global Rollout Kick-off Call moderiert',note:'2026-05-18 — Ronal, TXOne, Solcomp, K4'},
|
||||
]},
|
||||
]
|
||||
},
|
||||
branding: {
|
||||
name:'Branding & Außendarstellung', goal:'Logo, Farbschema, Typo, Fotos, Angebots-/Rechnungstemplate.',
|
||||
wipLimit:2, throughput:1, sle:{days:30,p:80},
|
||||
cols:[
|
||||
{id:'ready', label:'Ready', tasks:[{t:'Logo + Farbschema + Typo festlegen'},{t:'Professionelle Fotos — Termin vereinbaren'},{t:'Angebots-/Rechnungstemplate mit Pandoc+Typst'}]},
|
||||
{id:'wip', label:'In Progress', tasks:[]},
|
||||
{id:'done', label:'Done', tasks:[]},
|
||||
]
|
||||
},
|
||||
psk: {
|
||||
name:'PSK I Zertifizierung', goal:'PSK I bestanden ✅. Jetzt: Kurs bei K4 als Trainer durchführen.',
|
||||
wipLimit:1, throughput:1, sle:{days:7,p:95},
|
||||
cols:[
|
||||
{id:'ready', label:'Ready', tasks:[
|
||||
{t:'Control Chart ↔ Agent Chart gegenüberstellen'},
|
||||
{t:'Gyra aufhübschen — neues Beispiel (SLA + Definition of Workflow)'},
|
||||
{t:'Stammbaum: Lean → Agil → Kanban → Scrum'},
|
||||
{t:'Lead Time & Flow Efficiency — kurzer Abriss'},
|
||||
{t:'Frage 185: 3 vs. 5 Feedback Loops in Scrum klären'},
|
||||
{t:'SLE-Formel zugänglich erklären'},
|
||||
]},
|
||||
{id:'wip', label:'In Progress', tasks:[]},
|
||||
{id:'done', label:'Done', tasks:[{t:'Lernplan durcharbeiten',note:'Zertifizierungsphase abgeschlossen'},{t:'Mock-Prüfung / Self-Assessment',note:'PSK I bestanden ✅'}]},
|
||||
]
|
||||
},
|
||||
pleasance: {
|
||||
name:'Pleasance', goal:'Atelier-Hub-Site: Kontaktformular gebaut. RESEND_API_KEY + DNS + weitere Seiten ausstehend.',
|
||||
wipLimit:3, throughput:2, sle:{days:14,p:85},
|
||||
cols:[
|
||||
{id:'ready', label:'Ready', tasks:[
|
||||
{t:'RESEND_API_KEY in Coolify setzen',blocked:true},{t:'DNS A-Record api.pleasance.org → VPS-IP'},{t:'Resend + Kontaktformular-E-Mail testen'},
|
||||
{t:'Hetzner VPS bestellen + Coolify installieren'},{t:'pleasance.org von Vercel → Coolify migrieren'},{t:'Inhalte aus pleasance-thoughts integrieren'},{t:'studio.html + buehne.html + projekte.html'},{t:'img/robin.jpg besorgen'},
|
||||
]},
|
||||
{id:'wip', label:'In Progress', tasks:[]},
|
||||
{id:'done', label:'Done', tasks:[{t:'kontakt.html: Drei Doors → Formular'},{t:'coaching.html: alle Links → kontakt.html'},{t:'api/: Bun/Hono POST /contact mit Resend'},{t:'lernplatform-fork gelöscht',note:'Durch analyze-sources abgedeckt'}]},
|
||||
]
|
||||
},
|
||||
mdim: {
|
||||
name:'mydrugismusic Website', goal:'Website-Relaunch: Astro 5 + Directus CMS, deployed via Coolify.',
|
||||
wipLimit:2, throughput:2, sle:{days:21,p:85},
|
||||
cols:[
|
||||
{id:'ready', label:'Ready', tasks:[{t:'Aktuellen Stand prüfen + offene Tasks erfassen'}]},
|
||||
{id:'wip', label:'In Progress', tasks:[]},
|
||||
{id:'done', label:'Done', tasks:[]},
|
||||
]
|
||||
},
|
||||
eu: {
|
||||
name:'Einzelunternehmen', goal:'Buchhaltung, Banking und operative Verwaltung des Einzelunternehmens.',
|
||||
wipLimit:2, throughput:1, sle:{days:30,p:80},
|
||||
cols:[
|
||||
{id:'ready', label:'Ready', tasks:[{t:'Lexoffice anschauen + M26 sortieren'},{t:'Zweites Businesskonto eröffnen'}]},
|
||||
{id:'wip', label:'In Progress', tasks:[]},
|
||||
{id:'done', label:'Done', tasks:[]},
|
||||
]
|
||||
},
|
||||
privat: {
|
||||
name:'Haushalt & Leben', goal:'Persönliche Projekte, Haushalt, Anschaffungen.',
|
||||
wipLimit:2, throughput:2, sle:{days:14,p:80},
|
||||
cols:[
|
||||
{id:'ready', label:'Ready', tasks:[{t:'Roborock starten'},{t:'Wäsche anschmeißen'},{t:'Fahrradkette reparieren'},{t:'Analogkameras an die Wand bringen'},{t:'Bilder aufhängen'},{t:'Haken im Schlafzimmer aufhängen'},{t:'Korkpinwand fürs Büro kaufen'}]},
|
||||
{id:'wip', label:'In Progress', tasks:[]},
|
||||
{id:'done', label:'Done', tasks:[]},
|
||||
]
|
||||
},
|
||||
degoogle: {
|
||||
name:'De-Google / FOSS Migration', goal:'Migration zu self-hosted + FOSS. Nextcloud und Immich laufen bereits.',
|
||||
wipLimit:2, throughput:1, sle:{days:30,p:80},
|
||||
cols:[
|
||||
{id:'ready', label:'Ready', tasks:[
|
||||
{t:'Google Fotos → Immich Migration'},
|
||||
{t:'Matrix/Conduwuit self-hosted + Bridges'},{t:'Google Meet → Jitsi Meet self-hosted'},{t:'Apple Podcasts → AntennaPod / Podverse'},{t:'Apple Bücher → KOReader + Calibre-Web'},{t:'VPN: Mulvad einrichten'},{t:'Mail-Server evaluieren: Stalwart Mail'},{t:'GrapheneOS Migration (iPhone → Pixel)'},
|
||||
]},
|
||||
{id:'wip', label:'In Progress', tasks:[{t:'Navigation: Organic Maps installieren',age:7}]},
|
||||
{id:'done', label:'Done', tasks:[{t:'Proton Passwortmanager → Bitwarden',note:'256 Items nach Vaultwarden migriert'},{t:'Google Authenticator → Aegis / Raivo',note:'Alle 2FA-Codes nach Raivo OTP ✅'}]},
|
||||
]
|
||||
},
|
||||
bibliothek: {
|
||||
name:'Bibliothek-Pipeline', goal:'475 PDFs aus Nextcloud via /analyze-sources zu destillierten Markdown-Artikeln.',
|
||||
wipLimit:2, throughput:2, sle:{days:14,p:85},
|
||||
cols:[
|
||||
{id:'ready', label:'Ready', tasks:[{t:'Pilot: E-Book mit analyze-sources verarbeiten'},{t:'Marker auf x86_64 Mac klären'}]},
|
||||
{id:'wip', label:'In Progress', tasks:[]},
|
||||
{id:'done', label:'Done', tasks:[]},
|
||||
]
|
||||
},
|
||||
aikb: {
|
||||
name:'AI Engineering KB', goal:'Wissens-Repo für AI Engineering nach LLM-Wiki-Pattern (Karpathy).',
|
||||
wipLimit:2, throughput:2, sle:{days:14,p:85},
|
||||
cols:[
|
||||
{id:'ready', label:'Ready', tasks:[]},
|
||||
{id:'wip', label:'In Progress', tasks:[]},
|
||||
{id:'done', label:'Done', tasks:[{t:'Schema-Diff: source_type, author, year, isbn',note:'Frontmatter-Schema + Template dokumentiert'}]},
|
||||
]
|
||||
},
|
||||
tiefgang: {
|
||||
name:'Tiefgang', goal:'',
|
||||
wipLimit:3, throughput:2, sle:{days:14,p:85},
|
||||
cols:[
|
||||
{id:'ready', label:'Ready', tasks:[{t:'4 Songs mit KI-Tools mastern'},{t:'4 Songs in die Cloud hochladen'}]},
|
||||
{id:'wip', label:'In Progress', tasks:[]},
|
||||
{id:'done', label:'Done', tasks:[]},
|
||||
]
|
||||
},
|
||||
mdimmusic: {
|
||||
name:'MDIM', goal:'mydrugismusic — Label & Artist-Kollektiv.',
|
||||
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:[]},
|
||||
]
|
||||
},
|
||||
droii: {
|
||||
name:'DROII', goal:'',
|
||||
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:[]},
|
||||
]
|
||||
},
|
||||
einzelunternehmer: {
|
||||
name:'Einzelunternehmer', goal:'Finanzen, Buchhaltung, Steuern, Verwaltung als Freelancer.',
|
||||
wipLimit:3, throughput:1, sle:{days:30,p:85},
|
||||
cols:[
|
||||
{id:'ready', label:'Ready', tasks:[{t:'Lexoffice: Stand prüfen'}]},
|
||||
{id:'wip', label:'In Progress', tasks:[]},
|
||||
{id:'done', label:'Done', tasks:[]},
|
||||
]
|
||||
},
|
||||
soloprojekt: {
|
||||
name:'Soloprojekt', goal:'',
|
||||
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:[]},
|
||||
]
|
||||
},
|
||||
};
|
||||
function saveBoard(id) {
|
||||
if (!BOARDS[id]) return;
|
||||
fetch('/api/boards/' + id, { method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(BOARDS[id]) });
|
||||
}
|
||||
|
||||
// ── BOARD META (group + color + ring for defaults) ────────────────────────
|
||||
const BOARD_META = {
|
||||
ringsystem:{group:'Meta', color:'#7c6af7', ring:'0'},
|
||||
kanban: {group:'Meta', color:'#c084fc', ring:'3'},
|
||||
doener: {group:'Code', color:'#f87171', ring:'3'},
|
||||
musichub: {group:'Code', color:'#c084fc', ring:'1w'},
|
||||
openclaw: {group:'Code', color:'#60a5fa', ring:'1w'},
|
||||
docpilot: {group:'Code', color:'#7c6af7', ring:'1w'},
|
||||
k4: {group:'Beruflich', color:'#fbbf24', ring:'1w'},
|
||||
branding: {group:'Beruflich', color:'#4ade80', ring:'1w'},
|
||||
psk: {group:'Beruflich', color:'#4ade80', ring:'1w'},
|
||||
eu: {group:'Beruflich', color:'#fbbf24', ring:'1w'},
|
||||
pleasance: {group:'Web', color:'#f87171', ring:'3'},
|
||||
mdim: {group:'Web', color:'#c084fc', ring:'3'},
|
||||
privat: {group:'Privat', color:'#fb923c', ring:'1p'},
|
||||
degoogle: {group:'Privat', color:'#60a5fa', ring:'1p'},
|
||||
bibliothek:{group:'Privat', color:'#7c6af7', ring:'1p'},
|
||||
aikb: {group:'Privat', color:'#4ade80', ring:'1w'},
|
||||
tiefgang: {group:'Musik', color:'#2dd4bf', ring:'1p'},
|
||||
mdimmusic: {group:'Musik', color:'#c084fc', ring:'1p'},
|
||||
droii: {group:'Musik', color:'#f87171', ring:'1p'},
|
||||
soloprojekt: {group:'Musik', color:'#fbbf24', ring:'1p'},
|
||||
einzelunternehmer:{group:'Beruflich',color:'#4ade80', ring:'1p'},
|
||||
};
|
||||
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 = ['Meta','Code','Beruflich','Web','Privat','Musik'];
|
||||
let GROUPS = [];
|
||||
|
||||
function loadGroups() {
|
||||
try { const g = JSON.parse(localStorage.getItem('kanban_groups')); if (Array.isArray(g)) GROUPS = g; } catch {}
|
||||
if (!GROUPS.includes('Meta')) { GROUPS.unshift('Meta'); saveGroups(); }
|
||||
if (!GROUPS.includes('Musik')) { GROUPS.push('Musik'); saveGroups(); }
|
||||
}
|
||||
function saveGroups() { localStorage.setItem('kanban_groups', JSON.stringify(GROUPS)); }
|
||||
|
||||
let BOARD_ORDER = {}; // {group: [id, ...]} — explicit ordering within groups
|
||||
|
||||
function loadBoardOrder() {
|
||||
try { const o = JSON.parse(localStorage.getItem('kanban_board_order')); if (o && typeof o === 'object') BOARD_ORDER = o; } catch {}
|
||||
}
|
||||
function saveBoardOrder() { localStorage.setItem('kanban_board_order', JSON.stringify(BOARD_ORDER)); }
|
||||
let BOARD_ORDER = {};
|
||||
|
||||
function getBoardsForGroup(g) {
|
||||
const inGroup = Object.keys(BOARDS).filter(id => BOARDS[id] && BOARDS[id].group === g);
|
||||
@@ -966,11 +563,8 @@ function getBoardsForGroup(g) {
|
||||
return [...ord, ...rest];
|
||||
}
|
||||
|
||||
// ── STATE (localStorage) ──────────────────────────────────────────────────
|
||||
// ── STATE ─────────────────────────────────────────────────────────────────
|
||||
let BOARDS = {};
|
||||
let HIDDEN_BOARDS = new Set();
|
||||
function loadHidden() { try { HIDDEN_BOARDS = new Set(JSON.parse(localStorage.getItem('kanban_hidden') || '[]')); } catch {} }
|
||||
function saveHidden() { localStorage.setItem('kanban_hidden', JSON.stringify([...HIDDEN_BOARDS])); }
|
||||
|
||||
function deepClone(o) { return JSON.parse(JSON.stringify(o)); }
|
||||
|
||||
@@ -991,82 +585,12 @@ function migrateCols(board) {
|
||||
];
|
||||
}
|
||||
|
||||
function loadState() {
|
||||
BOARDS = deepClone(DEFAULTS);
|
||||
HIDDEN_BOARDS.forEach(id => { delete BOARDS[id]; });
|
||||
Object.keys(BOARDS).forEach(id => {
|
||||
const m = BOARD_META[id] || {group:'Privat', color:'#7c6af7', ring:'?'};
|
||||
BOARDS[id].group = m.group;
|
||||
BOARDS[id].color = m.color;
|
||||
BOARDS[id].ring = m.ring || '?';
|
||||
});
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('kanban_v2'));
|
||||
if (saved) {
|
||||
Object.keys(saved).forEach(id => {
|
||||
if (BOARDS[id]) {
|
||||
if (saved[id].cols) { BOARDS[id].cols = saved[id].cols; migrateCols(BOARDS[id]); }
|
||||
if (saved[id].focus !== undefined) BOARDS[id].focus = saved[id].focus;
|
||||
if (saved[id].name) BOARDS[id].name = saved[id].name;
|
||||
if (saved[id].goal !== undefined) BOARDS[id].goal = saved[id].goal;
|
||||
if (saved[id].wipLimit) BOARDS[id].wipLimit = saved[id].wipLimit;
|
||||
if (saved[id].throughput) BOARDS[id].throughput = saved[id].throughput;
|
||||
if (saved[id].sle) BOARDS[id].sle = saved[id].sle;
|
||||
if (saved[id].overview) BOARDS[id].overview = saved[id].overview;
|
||||
} else if (saved[id].userCreated) {
|
||||
BOARDS[id] = {
|
||||
name: saved[id].name || 'Board',
|
||||
goal: saved[id].goal || '',
|
||||
group: saved[id].group || 'Privat',
|
||||
color: saved[id].color || '#7c6af7',
|
||||
ring: saved[id].ring || '?',
|
||||
wipLimit: saved[id].wipLimit || 3,
|
||||
throughput: saved[id].throughput || 2,
|
||||
sle: saved[id].sle || {days:14,p:85},
|
||||
cols: saved[id].cols || [{id:'ready',label:'Ready',tasks:[]},{id:'wip',label:'In Progress',tasks:[]},{id:'done',label:'Done',tasks:[]}],
|
||||
focus: saved[id].focus || '',
|
||||
userCreated: true,
|
||||
};
|
||||
migrateCols(BOARDS[id]);
|
||||
if (!GROUPS.includes(BOARDS[id].group)) GROUPS.push(BOARDS[id].group);
|
||||
} else if (saved[id].ring) {
|
||||
BOARDS[id] && (BOARDS[id].ring = saved[id].ring);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function saveState() {
|
||||
const snap = {};
|
||||
Object.keys(BOARDS).forEach(id => {
|
||||
const b = BOARDS[id];
|
||||
snap[id] = {cols: b.cols, focus: b.focus || ''};
|
||||
const def = DEFAULTS[id];
|
||||
const defRing = BOARD_META[id]?.ring;
|
||||
if (b.ring && b.ring !== defRing) snap[id].ring = b.ring;
|
||||
if (def && b.name !== def.name) snap[id].name = b.name;
|
||||
if (def && (b.goal || '') !== (def.goal || '')) snap[id].goal = b.goal;
|
||||
if (def && b.wipLimit !== def.wipLimit) snap[id].wipLimit = b.wipLimit;
|
||||
if (def && b.throughput !== def.throughput) snap[id].throughput = b.throughput;
|
||||
if (def && JSON.stringify(b.sle) !== JSON.stringify(def.sle)) snap[id].sle = b.sle;
|
||||
if (def && b.overview && JSON.stringify(b.overview) !== JSON.stringify(def.overview || {})) {
|
||||
snap[id].overview = b.overview;
|
||||
}
|
||||
if (b.userCreated) Object.assign(snap[id], {
|
||||
name:b.name, goal:b.goal, group:b.group, color:b.color, ring:b.ring,
|
||||
wipLimit:b.wipLimit, throughput:b.throughput, sle:b.sle,
|
||||
overview:b.overview, userCreated:true,
|
||||
});
|
||||
});
|
||||
localStorage.setItem('kanban_v2', JSON.stringify(snap));
|
||||
}
|
||||
|
||||
function saveFocus() {
|
||||
const el = document.getElementById('focus-text');
|
||||
if (!el) return;
|
||||
BOARDS[curId].focus = el.textContent.trim();
|
||||
saveState();
|
||||
saveBoard(curId);
|
||||
}
|
||||
|
||||
function saveBoardName() {
|
||||
@@ -1076,7 +600,7 @@ function saveBoardName() {
|
||||
if (!newName) { el.textContent = BOARDS[curId].name; return; }
|
||||
if (newName === BOARDS[curId].name) return;
|
||||
BOARDS[curId].name = newName;
|
||||
saveState();
|
||||
saveBoard(curId);
|
||||
renderSidebar();
|
||||
}
|
||||
|
||||
@@ -1086,7 +610,7 @@ function saveBoardGoal() {
|
||||
const newGoal = el.textContent.trim();
|
||||
if (newGoal === (BOARDS[curId].goal || '')) return;
|
||||
BOARDS[curId].goal = newGoal;
|
||||
saveState();
|
||||
saveBoard(curId);
|
||||
}
|
||||
|
||||
function saveBoardSetting(key, el, min, max) {
|
||||
@@ -1102,7 +626,7 @@ function saveBoardSetting(key, el, min, max) {
|
||||
} else {
|
||||
BOARDS[curId][key] = val;
|
||||
}
|
||||
saveState();
|
||||
saveBoard(curId);
|
||||
show(curId);
|
||||
if (curView === 'analytics') renderAnalytics(curId);
|
||||
}
|
||||
@@ -1114,21 +638,13 @@ function showConfirm(msg, onOk) {
|
||||
}
|
||||
function closeConfirm() { document.getElementById('confirm-overlay').style.display = 'none'; }
|
||||
|
||||
function resetCurrentBoard() {
|
||||
const b = BOARDS[curId];
|
||||
if (b.userCreated) { deleteBoard(curId); return; }
|
||||
showConfirm(`"${b.name}" auf Standardzustand zurücksetzen?`, () => {
|
||||
BOARDS[curId].cols = deepClone(DEFAULTS[curId].cols);
|
||||
saveState(); show(curId); renderSidebar();
|
||||
});
|
||||
}
|
||||
function resetCurrentBoard() { deleteBoard(curId); }
|
||||
|
||||
function deleteBoard(id, e) {
|
||||
if (e) e.stopPropagation();
|
||||
showConfirm(`"${BOARDS[id]?.name}" wirklich löschen?`, () => {
|
||||
if (!BOARDS[id].userCreated) { HIDDEN_BOARDS.add(id); saveHidden(); }
|
||||
fetch('/api/boards/' + id, { method:'DELETE' });
|
||||
delete BOARDS[id];
|
||||
saveState();
|
||||
const firstId = Object.keys(BOARDS)[0];
|
||||
show(firstId); renderSidebar();
|
||||
});
|
||||
@@ -1141,7 +657,7 @@ function addCard(boardId, colId, text) {
|
||||
const task = {t: text, movedAt: now};
|
||||
if (colId === 'done') task.doneAt = now;
|
||||
col.tasks.push(task);
|
||||
saveState();
|
||||
saveBoard(boardId);
|
||||
show(boardId);
|
||||
renderSidebar();
|
||||
}
|
||||
@@ -1150,7 +666,7 @@ function deleteCard(boardId, colId, idx) {
|
||||
const col = BOARDS[boardId].cols.find(c => c.id === colId);
|
||||
if (!col) return;
|
||||
col.tasks.splice(idx, 1);
|
||||
saveState();
|
||||
saveBoard(boardId);
|
||||
show(boardId);
|
||||
renderSidebar();
|
||||
}
|
||||
@@ -1162,7 +678,7 @@ function cycleRing(boardId, e) {
|
||||
const cur = b.ring || '?';
|
||||
const idx = RINGS.indexOf(cur);
|
||||
b.ring = RINGS[(idx + 1) % RINGS.length];
|
||||
saveState();
|
||||
saveBoard(boardId);
|
||||
renderSidebar();
|
||||
}
|
||||
|
||||
@@ -1228,7 +744,7 @@ function confirmAddBoard() {
|
||||
const ng = prompt('Name der neuen Gruppe:');
|
||||
if (!ng || !ng.trim()) return;
|
||||
group = ng.trim();
|
||||
if (!GROUPS.includes(group)) { GROUPS.push(group); saveGroups(); }
|
||||
if (!GROUPS.includes(group)) { GROUPS.push(group); saveMeta(); }
|
||||
}
|
||||
const id = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
+ '-' + Date.now().toString(36);
|
||||
@@ -1240,10 +756,11 @@ function confirmAddBoard() {
|
||||
};
|
||||
if (promoteSource) {
|
||||
const srcCol = BOARDS[promoteSource.boardId]?.cols.find(c => c.id === promoteSource.colId);
|
||||
if (srcCol) srcCol.tasks.splice(promoteSource.idx, 1);
|
||||
if (srcCol) { srcCol.tasks.splice(promoteSource.idx, 1); saveBoard(promoteSource.boardId); }
|
||||
promoteSource = null;
|
||||
}
|
||||
saveState();
|
||||
saveBoard(id);
|
||||
saveMeta();
|
||||
closeAddBoard();
|
||||
show(id);
|
||||
renderSidebar();
|
||||
@@ -1258,7 +775,7 @@ function showAddGroup() {
|
||||
const name = prompt('Name der neuen Gruppe:');
|
||||
if (!name || !name.trim()) return;
|
||||
const g = name.trim();
|
||||
if (!GROUPS.includes(g)) { GROUPS.push(g); saveGroups(); renderSidebar(); }
|
||||
if (!GROUPS.includes(g)) { GROUPS.push(g); saveMeta(); renderSidebar(); }
|
||||
}
|
||||
|
||||
// ── DRAG & DROP ───────────────────────────────────────────────────────────
|
||||
@@ -1321,8 +838,8 @@ function onGroupDrop(e, group) {
|
||||
if (BOARD_ORDER[oldGroup]) BOARD_ORDER[oldGroup] = BOARD_ORDER[oldGroup].filter(id => id !== dragBoard);
|
||||
if (!BOARD_ORDER[group]) BOARD_ORDER[group] = getBoardsForGroup(group).filter(id => id !== dragBoard);
|
||||
BOARD_ORDER[group].push(dragBoard);
|
||||
saveBoardOrder();
|
||||
saveState();
|
||||
saveMeta();
|
||||
saveBoard(dragBoard);
|
||||
const db = dragBoard; dragBoard = null;
|
||||
renderSidebar();
|
||||
}
|
||||
@@ -1350,9 +867,9 @@ function onNavItemDrop(e, targetId, group) {
|
||||
BOARD_ORDER[group] = order;
|
||||
if (srcGroup !== group) {
|
||||
BOARD_ORDER[srcGroup] = getBoardsForGroup(srcGroup).filter(id => id !== dragBoard);
|
||||
saveState();
|
||||
saveBoard(dragBoard);
|
||||
}
|
||||
saveBoardOrder();
|
||||
saveMeta();
|
||||
const db = dragBoard; dragBoard = null;
|
||||
renderSidebar();
|
||||
}
|
||||
@@ -1398,7 +915,7 @@ function onGroupSectionDrop(e, group) {
|
||||
if (from === -1 || to === -1) return;
|
||||
GROUPS.splice(from, 1);
|
||||
GROUPS.splice(to, 0, dragGroup);
|
||||
saveGroups();
|
||||
saveMeta();
|
||||
dragGroup = null;
|
||||
renderSidebar();
|
||||
}
|
||||
@@ -1428,7 +945,7 @@ function onDropToIdeas(e, tag) {
|
||||
};
|
||||
if (!tag) delete idea.tag;
|
||||
IDEAS.unshift(idea);
|
||||
saveState();
|
||||
saveBoard(boardId);
|
||||
saveIdeas();
|
||||
dragSrc = null;
|
||||
showIdeaDropZones(false);
|
||||
@@ -1446,7 +963,7 @@ function onDrop(e, boardId, colId) {
|
||||
const col = BOARDS[boardId].cols.find(c => c.id === colId);
|
||||
if (col && idea) {
|
||||
col.tasks.push({t: idea.text});
|
||||
saveState();
|
||||
saveBoard(boardId);
|
||||
IDEAS.splice(dragIdea, 1);
|
||||
saveIdeas();
|
||||
show(boardId);
|
||||
@@ -1464,7 +981,7 @@ function onDrop(e, boardId, colId) {
|
||||
const task = srcCol.tasks[dragSrc.idx];
|
||||
if (!task) return;
|
||||
if (srcCol.id === dstCol.id) {
|
||||
if (task.cos === 'expedite') { delete task.cos; saveState(); show(boardId); }
|
||||
if (task.cos === 'expedite') { delete task.cos; saveBoard(boardId); show(boardId); }
|
||||
dragSrc = null;
|
||||
return;
|
||||
}
|
||||
@@ -1474,7 +991,7 @@ function onDrop(e, boardId, colId) {
|
||||
if (dstCol.id === 'done') moved.doneAt = Date.now();
|
||||
dstCol.tasks.push(moved);
|
||||
dragSrc = null;
|
||||
saveState();
|
||||
saveBoard(boardId);
|
||||
show(boardId);
|
||||
initBadges();
|
||||
}
|
||||
@@ -1500,14 +1017,14 @@ function onDropToExpCol(e, boardId, colId) {
|
||||
if (dstCol.id === 'done') task.doneAt = Date.now();
|
||||
dstCol.tasks.push(task);
|
||||
dragSrc = null;
|
||||
saveState();
|
||||
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; saveState(); show(boardId); }
|
||||
if (col && col.tasks[idx]) { delete col.tasks[idx].cos; saveBoard(boardId); show(boardId); }
|
||||
}
|
||||
|
||||
// ── UTILS ─────────────────────────────────────────────────────────────────
|
||||
@@ -1551,7 +1068,7 @@ function toggleBlocker(boardId, colId, idx) {
|
||||
const task = col.tasks[idx];
|
||||
task.blocked = !task.blocked;
|
||||
if (!task.blocked) delete task.blocked;
|
||||
saveState();
|
||||
saveBoard(boardId);
|
||||
show(boardId);
|
||||
initBadges();
|
||||
}
|
||||
@@ -1938,7 +1455,7 @@ function toggleSecretBtn(btn) {
|
||||
const ov = BOARDS[curId].overview;
|
||||
if (!ov || !ov.secrets || !ov.secrets[idx]) return;
|
||||
ov.secrets[idx].masked = !ov.secrets[idx].masked;
|
||||
saveState();
|
||||
saveBoard(curId);
|
||||
renderOverview(curId);
|
||||
}
|
||||
|
||||
@@ -1957,7 +1474,7 @@ function updateOvFromEl(el) {
|
||||
if (prop) ov[section][idx][prop] = value;
|
||||
else ov[section][idx] = value;
|
||||
}
|
||||
saveState();
|
||||
saveBoard(curId);
|
||||
}
|
||||
|
||||
function addOvRow(section) {
|
||||
@@ -1971,7 +1488,7 @@ function addOvRow(section) {
|
||||
commands: {label:'', cmd:''},
|
||||
}[section] || {};
|
||||
ov[section].push(blank);
|
||||
saveState();
|
||||
saveBoard(curId);
|
||||
renderOverview(curId);
|
||||
}
|
||||
|
||||
@@ -1980,7 +1497,7 @@ function addOvPill(section, value) {
|
||||
const ov = BOARDS[curId].overview;
|
||||
if (!ov[section]) ov[section] = [];
|
||||
ov[section].push(value);
|
||||
saveState();
|
||||
saveBoard(curId);
|
||||
renderOverview(curId);
|
||||
}
|
||||
|
||||
@@ -1988,7 +1505,7 @@ function deleteOvRow(section, idx) {
|
||||
const ov = BOARDS[curId].overview;
|
||||
if (!ov || !ov[section]) return;
|
||||
ov[section].splice(idx, 1);
|
||||
saveState();
|
||||
saveBoard(curId);
|
||||
renderOverview(curId);
|
||||
}
|
||||
|
||||
@@ -2297,20 +1814,7 @@ const SEED_IDEAS = [
|
||||
{id:3016, text:'Fear-Setting (Ferriss) als wiederverwendbares Decision-Framework: Define/Prevent/Repair-Tabelle', tag:'erkenntnis', created:'archive/fear-setting'},
|
||||
];
|
||||
|
||||
const SEED_VERSION = 'v3';
|
||||
|
||||
function loadIdeas() {
|
||||
try { IDEAS = JSON.parse(localStorage.getItem('kanban_ideas')) || []; } catch { IDEAS = []; }
|
||||
const seeded = localStorage.getItem('kanban_ideas_seeded');
|
||||
if (seeded !== SEED_VERSION) {
|
||||
const existingIds = new Set(IDEAS.map(i => i.id));
|
||||
SEED_IDEAS.forEach(s => { if (!existingIds.has(s.id)) IDEAS.push(s); });
|
||||
IDEAS.sort((a,b) => (a.id||0) - (b.id||0));
|
||||
saveIdeas();
|
||||
localStorage.setItem('kanban_ideas_seeded', SEED_VERSION);
|
||||
}
|
||||
}
|
||||
function saveIdeas() { localStorage.setItem('kanban_ideas', JSON.stringify(IDEAS)); }
|
||||
|
||||
|
||||
function addIdea() {
|
||||
@@ -2356,7 +1860,7 @@ function confirmPromote() {
|
||||
const colId = document.getElementById('promote-col').value;
|
||||
const col = BOARDS[boardId].cols.find(c => c.id === colId);
|
||||
if (!col) return;
|
||||
col.tasks.push({ t: idea.text }); saveState(); initBadges(); if (curId === boardId) show(boardId);
|
||||
col.tasks.push({ t: idea.text }); saveBoard(boardId); initBadges(); if (curId === boardId) show(boardId);
|
||||
IDEAS.splice(promoteIdx, 1);
|
||||
saveIdeas();
|
||||
promoteIdx = null;
|
||||
@@ -2414,46 +1918,15 @@ function renderIdeas() {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── EXPORT / IMPORT ──────────────────────────────────────────────────────────
|
||||
function exportState() {
|
||||
const keys = ['kanban_v2','kanban_groups','kanban_board_order','kanban_ideas','kanban_ideas_seeded','kanban_hidden'];
|
||||
const snap = {};
|
||||
keys.forEach(k => { const v = localStorage.getItem(k); if (v !== null) snap[k] = v; });
|
||||
const blob = new Blob([JSON.stringify(snap, null, 2)], {type:'application/json'});
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `kanban-backup-${new Date().toISOString().slice(0,10)}.json`;
|
||||
a.click();
|
||||
}
|
||||
|
||||
function importState(input) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
try {
|
||||
const snap = JSON.parse(e.target.result);
|
||||
Object.entries(snap).forEach(([k, v]) => localStorage.setItem(k, v));
|
||||
input.value = '';
|
||||
location.reload();
|
||||
} catch { alert('Ungültige Backup-Datei.'); }
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
// ── INIT ──────────────────────────────────────────────────────────────────
|
||||
if (window.location.protocol === 'file:') {
|
||||
document.getElementById('proto-banner').style.display = 'flex';
|
||||
}
|
||||
loadGroups();
|
||||
loadBoardOrder();
|
||||
loadHidden();
|
||||
loadState();
|
||||
(async () => {
|
||||
await fetchState();
|
||||
renderSidebar();
|
||||
loadIdeas();
|
||||
renderIdeas();
|
||||
show('doener');
|
||||
setView('overview');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
5
package.json
Normal file
5
package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"hono": "^4"
|
||||
}
|
||||
}
|
||||
99
server.ts
Normal file
99
server.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Hono } from 'hono';
|
||||
import { basicAuth } from 'hono/basic-auth';
|
||||
import { serveStatic } from 'hono/bun';
|
||||
import { existsSync, mkdirSync, renameSync, unlinkSync } from 'fs';
|
||||
import { readdir } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import defaults from './defaults.json';
|
||||
|
||||
const PORT = parseInt(Bun.env.PORT || '3000');
|
||||
export const DATA_DIR = Bun.env.DATA_DIR || path.join(import.meta.dir, 'data');
|
||||
const BOARDS_DIR = path.join(DATA_DIR, 'boards');
|
||||
|
||||
mkdirSync(BOARDS_DIR, { recursive: true });
|
||||
|
||||
async function atomicWrite(filePath: string, content: string) {
|
||||
const tmp = filePath + '.tmp';
|
||||
await Bun.write(tmp, content);
|
||||
renameSync(tmp, filePath);
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
if (existsSync(path.join(DATA_DIR, 'meta.json'))) return;
|
||||
await atomicWrite(path.join(DATA_DIR, 'meta.json'), JSON.stringify({
|
||||
groups: defaults.groups,
|
||||
boardOrder: {},
|
||||
}, null, 2));
|
||||
await atomicWrite(path.join(DATA_DIR, 'ideas.json'), JSON.stringify({ ideas: [] }, null, 2));
|
||||
for (const [id, board] of Object.entries(defaults.boards)) {
|
||||
const meta = (defaults.boardMeta as Record<string, { group: string; color: string; ring: string }>)[id]
|
||||
|| { group: 'Privat', color: '#7c6af7', ring: '?' };
|
||||
await atomicWrite(path.join(BOARDS_DIR, `${id}.json`), JSON.stringify({ ...board, id, ...meta }, null, 2));
|
||||
}
|
||||
console.log('Bootstrap: initialisiert mit', Object.keys(defaults.boards).length, 'Boards');
|
||||
}
|
||||
|
||||
await bootstrap();
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
if (Bun.env.KANBAN_USER && Bun.env.KANBAN_PASS) {
|
||||
app.use('*', basicAuth({ username: Bun.env.KANBAN_USER!, password: Bun.env.KANBAN_PASS! }));
|
||||
}
|
||||
|
||||
app.get('/api/state', async (c) => {
|
||||
const metaPath = path.join(DATA_DIR, 'meta.json');
|
||||
const ideasPath = path.join(DATA_DIR, 'ideas.json');
|
||||
const meta = existsSync(metaPath)
|
||||
? JSON.parse(await Bun.file(metaPath).text())
|
||||
: { groups: defaults.groups, boardOrder: {} };
|
||||
const ideas = existsSync(ideasPath)
|
||||
? (JSON.parse(await Bun.file(ideasPath).text())).ideas ?? []
|
||||
: [];
|
||||
const boards: Record<string, unknown> = {};
|
||||
const files = await readdir(BOARDS_DIR).catch(() => [] as string[]);
|
||||
for (const f of files.filter(f => f.endsWith('.json'))) {
|
||||
const id = f.slice(0, -5);
|
||||
boards[id] = JSON.parse(await Bun.file(path.join(BOARDS_DIR, f)).text());
|
||||
}
|
||||
return c.json({ meta, boards, ideas });
|
||||
});
|
||||
|
||||
app.put('/api/boards/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
if (!/^[a-z0-9_-]+$/i.test(id)) return c.json({ error: 'invalid id' }, 400);
|
||||
const body = await c.req.text();
|
||||
try { JSON.parse(body); } catch { return c.json({ error: 'invalid json' }, 400); }
|
||||
await atomicWrite(path.join(BOARDS_DIR, `${id}.json`), body);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete('/api/boards/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const fp = path.join(BOARDS_DIR, `${id}.json`);
|
||||
if (existsSync(fp)) unlinkSync(fp);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.put('/api/meta', async (c) => {
|
||||
const body = await c.req.text();
|
||||
try { JSON.parse(body); } catch { return c.json({ error: 'invalid json' }, 400); }
|
||||
await atomicWrite(path.join(DATA_DIR, 'meta.json'), body);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.put('/api/ideas', async (c) => {
|
||||
const body = await c.req.text();
|
||||
try { JSON.parse(body); } catch { return c.json({ error: 'invalid json' }, 400); }
|
||||
await atomicWrite(path.join(DATA_DIR, 'ideas.json'), body);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.use('/assets/*', serveStatic({ root: './' }));
|
||||
|
||||
app.get('*', async () => new Response(Bun.file(path.join(import.meta.dir, 'index.html')), {
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
}));
|
||||
|
||||
Bun.serve({ fetch: app.fetch, port: PORT });
|
||||
console.log(`Kanban läuft auf http://localhost:${PORT}`);
|
||||
Reference in New Issue
Block a user