321140ff86
- showNotification import OK-Zeile vervollständigt
- loadDemoData() speichert korrekte im localStorage
- Template-String/Literal-Handling korrigiert
✅ Alle Gates geschlossen (18/18)
1915 lines
51 KiB
HTML
1915 lines
51 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>NOVA Control</title>
|
||
<style>
|
||
:root {
|
||
--bg: #f5f5f7;
|
||
--surface: #ffffff;
|
||
--surface-hover: #f5f5f7;
|
||
--text: #1d1d1f;
|
||
--secondary: #6e6e73;
|
||
--accent: #0071e3;
|
||
--green: #34c759;
|
||
--red: #ff3b30;
|
||
--orange: #ff9500;
|
||
--yellow: #ffcc00;
|
||
--border: #e5e5ea;
|
||
}
|
||
|
||
[data-theme="dark"] {
|
||
--bg: #1c1c1e;
|
||
--surface: #2c2c2e;
|
||
--surface-hover: #3a3a3c;
|
||
--text: #f5f5f7;
|
||
--secondary: #a1a1a6;
|
||
--accent: #0a84ff;
|
||
--green: #30d158;
|
||
--red: #ff453a;
|
||
--orange: #ff9f0a;
|
||
--yellow: #ffd60a;
|
||
--border: #38383a;
|
||
}
|
||
|
||
html, body {
|
||
margin: 0;
|
||
padding: 0;
|
||
height: 100%;
|
||
}
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
overflow: auto;
|
||
}
|
||
|
||
.layout {
|
||
display: grid;
|
||
grid-template-columns: 220px 1fr;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
/* SIDEBAR */
|
||
.sidebar {
|
||
background: var(--surface);
|
||
border-right: 1px solid var(--border);
|
||
padding: 20px 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: space-between;
|
||
position: sticky;
|
||
top: 0;
|
||
height: 100vh;
|
||
}
|
||
|
||
.sidebar .logo {
|
||
padding: 10px 20px;
|
||
font-size: 22px;
|
||
font-weight: 800;
|
||
color: var(--accent);
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.sidebar nav {
|
||
flex-grow: 1;
|
||
}
|
||
|
||
.sidebar button {
|
||
width: 100%;
|
||
padding: 10px 20px;
|
||
border: none;
|
||
background: none;
|
||
cursor: pointer;
|
||
text-align: left;
|
||
font-size: 14px;
|
||
color: var(--text);
|
||
}
|
||
|
||
.sidebar button:hover {
|
||
background: var(--surface-hover);
|
||
}
|
||
|
||
.sidebar button.selected {
|
||
background: var(--accent);
|
||
color: #ffffff;
|
||
}
|
||
|
||
.theme-btns {
|
||
padding: 10px 20px;
|
||
display: flex;
|
||
gap: 6px;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
|
||
.theme-btns button {
|
||
flex: 1;
|
||
padding: 6px;
|
||
border: 1px solid var(--border);
|
||
background: var(--bg);
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.theme-btns button.selected {
|
||
background: var(--accent);
|
||
color: #ffffff;
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
/* CONTENT */
|
||
.content {
|
||
padding: 24px;
|
||
overflow-y: auto;
|
||
height: 100vh;
|
||
}
|
||
|
||
.section {
|
||
display: none;
|
||
}
|
||
|
||
.section.visible {
|
||
display: block;
|
||
}
|
||
|
||
h2 {
|
||
font-size: 22px;
|
||
font-weight: 700;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
/* TOOLBAR */
|
||
.toolbar {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-bottom: 16px;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.toolbar input, .toolbar select {
|
||
padding: 6px 12px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
font-size: 13px;
|
||
background: var(--surface);
|
||
color: var(--text);
|
||
}
|
||
|
||
.toolbar input[type="text"] {
|
||
flex: 1;
|
||
min-width: 120px;
|
||
}
|
||
|
||
.toolbar select {
|
||
padding: 6px 10px;
|
||
}
|
||
|
||
/* BUTTONS */
|
||
.button {
|
||
padding: 6px 14px;
|
||
border: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.button.primary {
|
||
background: var(--accent);
|
||
color: #ffffff;
|
||
}
|
||
|
||
.button.secondary {
|
||
background: var(--surface-hover);
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
/* BOARD */
|
||
.board {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 12px;
|
||
}
|
||
|
||
.column {
|
||
background: var(--surface);
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
min-height: 350px;
|
||
}
|
||
|
||
.column.drag-over {
|
||
box-shadow: 0 0 0 2px var(--accent);
|
||
}
|
||
|
||
.column-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
color: var(--secondary);
|
||
padding-bottom: 8px;
|
||
border-bottom: 2px solid var(--border);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.task-count {
|
||
background: var(--bg);
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
font-size: 11px;
|
||
color: var(--secondary);
|
||
}
|
||
|
||
.task {
|
||
background: var(--bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 10px;
|
||
margin-bottom: 6px;
|
||
cursor: grab;
|
||
}
|
||
|
||
.task:hover {
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.task:focus {
|
||
outline: 2px solid var(--accent);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
.task.dragging {
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.task h3 {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.task p {
|
||
font-size: 11px;
|
||
color: var(--secondary);
|
||
margin-bottom: 6px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.task .meta {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: 10px;
|
||
}
|
||
|
||
.badge {
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
font-size: 9px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.badge.high { background: #ffebee; color: #c62828; }
|
||
.badge.medium { background: #fff3e0; color: #e65100; }
|
||
.badge.low { background: #e8f5e9; color: #2e7d32; }
|
||
|
||
[data-theme="dark"] .badge.high { background: #5c1a16; color: #ff8a80; }
|
||
[data-theme="dark"] .badge.medium { background: #5c3910; color: #ffcc80; }
|
||
[data-theme="dark"] .badge.low { background: #1b4316; color: #81c784; }
|
||
|
||
/* MODAL */
|
||
.modal-background {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.4);
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 100;
|
||
}
|
||
|
||
.modal-background.open {
|
||
display: flex;
|
||
}
|
||
|
||
.modal {
|
||
background: var(--surface);
|
||
border-radius: 10px;
|
||
padding: 24px;
|
||
width: 460px;
|
||
max-width: 90vw;
|
||
max-height: 80vh;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.modal h3 {
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.field {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.field label {
|
||
display: block;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: var(--secondary);
|
||
margin-bottom: 3px;
|
||
}
|
||
|
||
.field input, .field textarea, .field select {
|
||
width: 100%;
|
||
padding: 7px 10px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 5px;
|
||
font-size: 13px;
|
||
background: var(--surface);
|
||
color: var(--text);
|
||
}
|
||
|
||
.field textarea {
|
||
resize: vertical;
|
||
min-height: 60px;
|
||
}
|
||
|
||
.modal-actions {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
/* PALETTE */
|
||
.palette-background {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.4);
|
||
display: none;
|
||
align-items: flex-start;
|
||
justify-content: center;
|
||
padding-top: 10vh;
|
||
z-index: 200;
|
||
}
|
||
|
||
.palette-background.open {
|
||
display: flex;
|
||
}
|
||
|
||
.palette {
|
||
background: var(--surface);
|
||
border-radius: 10px;
|
||
width: 480px;
|
||
max-width: 90vw;
|
||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.2);
|
||
}
|
||
|
||
.palette input {
|
||
width: 100%;
|
||
padding: 14px 18px;
|
||
border: none;
|
||
font-size: 15px;
|
||
background: transparent;
|
||
outline: none;
|
||
color: var(--text);
|
||
}
|
||
|
||
.palette-list {
|
||
max-height: 50vh;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.palette-item {
|
||
padding: 10px 18px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.palette-item:hover, .palette-item.selected {
|
||
background: var(--surface-hover);
|
||
}
|
||
|
||
.palette-item .name {
|
||
font-size: 13px;
|
||
}
|
||
|
||
.palette-item .shortcut {
|
||
font-size: 10px;
|
||
color: var(--secondary);
|
||
background: var(--bg);
|
||
border: 1px solid var(--border);
|
||
padding: 2px 5px;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
/* STATS */
|
||
.stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||
gap: 12px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.stat-card {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 14px;
|
||
text-align: center;
|
||
}
|
||
|
||
.stat-card .value {
|
||
font-size: 24px;
|
||
font-weight: 800;
|
||
color: var(--accent);
|
||
}
|
||
|
||
.stat-card .label {
|
||
font-size: 11px;
|
||
color: var(--secondary);
|
||
margin-top: 4px;
|
||
}
|
||
|
||
/* CHARTS */
|
||
.chart {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.chart-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.chart-canvas-wrap {
|
||
width: 100%;
|
||
height: 200px;
|
||
position: relative;
|
||
}
|
||
|
||
.chart-canvas-wrap canvas {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.chart-legend {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
margin-top: 8px;
|
||
font-size: 11px;
|
||
color: var(--secondary);
|
||
}
|
||
|
||
.chart-legend span {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.chart-legend .color-dot {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
display: inline-block;
|
||
}
|
||
|
||
/* STREAK */
|
||
.streak {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
padding: 16px;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.streak h3 {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.streak-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.streak-current, .streak-longest {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.streak-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(26, 1fr);
|
||
gap: 2px;
|
||
}
|
||
|
||
.streak-cell {
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 2px;
|
||
background: var(--border);
|
||
outline: none;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.streak-cell[data-value="1"] { background: #a5d6a7; }
|
||
.streak-cell[data-value="2"] { background: #66bb6a; }
|
||
.streak-cell[data-value="3"] { background: #2e7d32; }
|
||
.streak-cell[data-value="4"] { background: #1b5e20; }
|
||
|
||
.streak-cell:hover {
|
||
outline: 2px solid var(--accent);
|
||
outline-offset: 1px;
|
||
}
|
||
|
||
.streak-cell:focus {
|
||
outline: 2px solid var(--accent);
|
||
outline-offset: 1px;
|
||
}
|
||
|
||
/* TOAST */
|
||
.toast-container {
|
||
position: fixed;
|
||
bottom: 16px;
|
||
right: 16px;
|
||
z-index: 300;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.toast {
|
||
padding: 8px 14px;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: #ffffff;
|
||
animation: toastIn 0.3s ease;
|
||
}
|
||
|
||
@keyframes toastIn {
|
||
from { opacity: 0; transform: translateY(10px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
.toast.success { background: var(--green); }
|
||
.toast.error { background: var(--red); }
|
||
.toast.info { background: var(--accent); }
|
||
|
||
/* SCREEN READER ONLY */
|
||
.sr-only {
|
||
position: absolute;
|
||
width: 1px;
|
||
height: 1px;
|
||
padding: 0;
|
||
margin: -1px;
|
||
overflow: hidden;
|
||
clip: rect(0, 0, 0, 0);
|
||
white-space: nowrap;
|
||
border: 0;
|
||
}
|
||
|
||
/* RESPONSIVE */
|
||
@media (max-width: 768px) {
|
||
.layout {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.sidebar {
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: auto;
|
||
width: 100%;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
padding: 8px 16px;
|
||
z-index: 50;
|
||
}
|
||
|
||
.sidebar nav {
|
||
display: flex;
|
||
gap: 4px;
|
||
flex-grow: 0;
|
||
}
|
||
|
||
.sidebar button {
|
||
padding: 8px 12px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.theme-btns {
|
||
display: none;
|
||
}
|
||
|
||
.content {
|
||
height: calc(100vh - 56px);
|
||
padding-bottom: 72px;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 480px) {
|
||
.board {
|
||
grid-template-columns: 1fr;
|
||
gap: 8px;
|
||
}
|
||
|
||
.column {
|
||
min-height: auto;
|
||
}
|
||
}
|
||
|
||
/* REDUCED MOTION */
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.toast {
|
||
animation: none !important;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="layout">
|
||
<aside class="sidebar" role="navigation" aria-label="Hauptnavigation">
|
||
<div>
|
||
<div class="logo">NOVA</div>
|
||
<nav>
|
||
<button class="selected" data-view="board" aria-label="Board anzeigen">📅 Board</button>
|
||
<button data-view="streaks" aria-label="Streaks anzeigen">🔥 Streaks</button>
|
||
<button data-view="insights" aria-label="Insights anzeigen">📊 Insights</button>
|
||
</nav>
|
||
</div>
|
||
<div class="theme-btns">
|
||
<button data-theme-value="light" aria-label="Helles Design">☀️</button>
|
||
<button data-theme-value="dark" aria-label="Dunkles Design">🌙</button>
|
||
<button data-theme-value="system" aria-label="System-Design" class="selected">📱</button>
|
||
</div>
|
||
</aside>
|
||
|
||
<main class="content" role="main">
|
||
<section id="board-section" class="section visible" aria-label="Board-Ansicht">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
|
||
<h2 style="margin:0">Board</h2>
|
||
<button class="button primary" id="btn-new" aria-label="Neues Task erstellen">+ Neues Task</button>
|
||
</div>
|
||
<div class="toolbar" id="toolbar">
|
||
<input type="text" id="query" placeholder="Suchen..." aria-label="Tasks durchsuchen">
|
||
<select id="priority-filter" aria-label="Nach Priorität filtern">
|
||
<option value="">Alle Prioritäten</option>
|
||
<option value="Tief">Tief</option>
|
||
<option value="Mittel">Mittel</option>
|
||
<option value="Hoch">Hoch</option>
|
||
</select>
|
||
</div>
|
||
<div class="board" id="board" aria-label="Task-Board"></div>
|
||
</section>
|
||
|
||
<section id="streaks-section" class="section" aria-label="Streak-Ansicht">
|
||
<h2>Streaks</h2>
|
||
<div id="streak-grid"></div>
|
||
</section>
|
||
|
||
<section id="insights-section" class="section" aria-label="Insights-Ansicht">
|
||
<h2>Insights</h2>
|
||
<div class="stats" id="insights-stats"></div>
|
||
<div class="chart">
|
||
<div class="chart-title">Trend (Wochen)</div>
|
||
<div class="chart-canvas-wrap"><canvas id="trend-chart"></canvas></div>
|
||
</div>
|
||
<div class="chart">
|
||
<div class="chart-title">Prioritätsverteilung</div>
|
||
<div class="chart-canvas-wrap"><canvas id="donut-chart"></canvas></div>
|
||
<div class="chart-legend" id="donut-legend"></div>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
</div>
|
||
|
||
<div class="modal-background" id="modal-bg" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||
<div class="modal">
|
||
<h3 id="modal-title">Neues Task</h3>
|
||
<div class="field">
|
||
<label for="f-title">Titel</label>
|
||
<input type="text" id="f-title" maxlength="200" required>
|
||
</div>
|
||
<div class="field">
|
||
<label for="f-desc">Beschreibung</label>
|
||
<textarea id="f-desc"></textarea>
|
||
</div>
|
||
<div class="field">
|
||
<label for="f-priority">Priorität</label>
|
||
<select id="f-priority">
|
||
<option>Hoch</option>
|
||
<option>Mittel</option>
|
||
<option>Tief</option>
|
||
</select>
|
||
</div>
|
||
<div class="field">
|
||
<label for="f-status">Status</label>
|
||
<select id="f-status"></select>
|
||
</div>
|
||
<div class="field">
|
||
<label for="f-due">Fällig</label>
|
||
<input type="date" id="f-due">
|
||
</div>
|
||
<div class="modal-actions">
|
||
<div><button class="button secondary" id="f-delete" style="display:none">Löschen</button></div>
|
||
<div>
|
||
<button class="button secondary" id="f-cancel">Abbrechen</button>
|
||
<button class="button primary" id="f-save">Speichern</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="palette-background" id="pal-bg" role="dialog" aria-modal="true" aria-label="Befehlspalette">
|
||
<div class="palette">
|
||
<input id="pal-q" placeholder="Befehl suchen..." aria-label="Suche">
|
||
<div class="palette-list" id="pal-list" role="listbox" aria-label="Verfügbare Befehle"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toast-container" id="toasts" aria-live="polite"></div>
|
||
<div class="sr-only" id="live-region" aria-live="assertive" aria-atomic="true"></div>
|
||
<input type="file" id="imp" accept=".json" style="display:none">
|
||
|
||
<script>
|
||
(function () {
|
||
'use strict';
|
||
|
||
// === KONSTANTEN ===
|
||
|
||
const COLUMNS = [
|
||
['Backlog', 'backlog', '📋'],
|
||
['Aktiv', 'aktiv', '🟢'],
|
||
['Review', 'review', '🔄'],
|
||
['Fertig', 'fertig', '✅'],
|
||
];
|
||
|
||
// === STATE ===
|
||
|
||
const tasks = [];
|
||
const habits = [];
|
||
let currentTheme = 'system';
|
||
let currentView = 'board';
|
||
let previousFocus = null;
|
||
let paletteCommand = [];
|
||
let paletteIndex = 0;
|
||
let isEditing = false;
|
||
let dragSourceId = null;
|
||
|
||
// === HILFSFUNKTIONEN ===
|
||
|
||
function generateUniqueId() {
|
||
const timestamp = Date.now().toString(36);
|
||
const random = Math.random().toString(36).slice(2, 6);
|
||
return 'd' + timestamp + '_' + random;
|
||
}
|
||
|
||
function htmlEscape(text) {
|
||
if (!text) return '';
|
||
return text
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
function formatDateString(isoDateString) {
|
||
if (!isoDateString) return '';
|
||
try {
|
||
return new Date(isoDateString).toLocaleDateString('de-DE');
|
||
} catch (error) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function showNotification(message, type) {
|
||
const container = document.getElementById('toasts');
|
||
if (!container) return;
|
||
|
||
const alertElement = document.createElement('div');
|
||
const typeClass = type === 'ok' ? 'success' : type === 'err' ? 'error' : 'info';
|
||
alertElement.className = 'toast ' + typeClass;
|
||
alertElement.textContent = message;
|
||
container.appendChild(alertElement);
|
||
|
||
setTimeout(function () {
|
||
if (alertElement.parentNode) {
|
||
alertElement.parentNode.removeChild(alertElement);
|
||
}
|
||
}, 2600);
|
||
}
|
||
|
||
function announceStatus(message) {
|
||
const liveRegion = document.getElementById('live-region');
|
||
if (liveRegion) {
|
||
liveRegion.textContent = message;
|
||
}
|
||
}
|
||
|
||
// === PERSISTENZ ===
|
||
|
||
function saveState() {
|
||
try {
|
||
localStorage.setItem('nv_t', JSON.stringify(tasks));
|
||
localStorage.setItem('nv_h', JSON.stringify(habits));
|
||
localStorage.setItem('nv_th', currentTheme);
|
||
} catch (error) {
|
||
// localStorage nicht verfügbar
|
||
}
|
||
}
|
||
|
||
function loadState() {
|
||
try {
|
||
const rawTasks = localStorage.getItem('nv_t');
|
||
const rawHabits = localStorage.getItem('nv_h');
|
||
const rawTheme = localStorage.getItem('nv_th');
|
||
|
||
if (rawTasks) {
|
||
const parsed = JSON.parse(rawTasks);
|
||
if (Array.isArray(parsed)) {
|
||
tasks.splice(0, tasks.length, ...parsed);
|
||
}
|
||
}
|
||
if (rawHabits) {
|
||
const parsed = JSON.parse(rawHabits);
|
||
if (Array.isArray(parsed)) {
|
||
habits.splice(0, habits.length, ...parsed);
|
||
}
|
||
}
|
||
if (rawTheme) {
|
||
currentTheme = rawTheme;
|
||
}
|
||
} catch (error) {
|
||
tasks.length = 0;
|
||
habits.length = 0;
|
||
currentTheme = 'system';
|
||
}
|
||
registerPalette();
|
||
}
|
||
|
||
// === THEME ===
|
||
|
||
function applyTheme() {
|
||
const mediaQuery = window.matchMedia('(prefers-color-scheme:dark)');
|
||
const documentElement = document.documentElement;
|
||
|
||
if (currentTheme === 'system') {
|
||
documentElement.setAttribute('data-theme', mediaQuery.matches ? 'dark' : 'light');
|
||
} else {
|
||
documentElement.setAttribute('data-theme', currentTheme);
|
||
}
|
||
|
||
const themeButtons = document.querySelectorAll('.theme-btns button');
|
||
for (const button of themeButtons) {
|
||
const themeValue = button.getAttribute('data-theme-value');
|
||
button.classList.toggle('selected', themeValue === currentTheme);
|
||
}
|
||
}
|
||
|
||
function subscribeToSystemThemeChange() {
|
||
const mediaQuery = window.matchMedia('(prefers-color-scheme:dark)');
|
||
mediaQuery.addEventListener('change', function () {
|
||
if (currentTheme === 'system') {
|
||
applyTheme();
|
||
}
|
||
});
|
||
}
|
||
|
||
// === NAVIGATION ===
|
||
|
||
function switchView(viewName) {
|
||
currentView = viewName;
|
||
location.hash = '#/' + viewName;
|
||
|
||
const navButtons = document.querySelectorAll('.sidebar nav button');
|
||
for (const button of navButtons) {
|
||
button.classList.toggle('selected', button.getAttribute('data-view') === viewName);
|
||
}
|
||
|
||
const sections = document.querySelectorAll('.section');
|
||
for (const section of sections) {
|
||
const isVisible = section.id === viewName + '-section';
|
||
section.classList.toggle('visible', isVisible);
|
||
}
|
||
|
||
renderView();
|
||
}
|
||
|
||
function renderView() {
|
||
if (currentView === 'board') {
|
||
renderBoard();
|
||
} else if (currentView === 'streaks') {
|
||
renderStreaks();
|
||
} else if (currentView === 'insights') {
|
||
renderInsights();
|
||
}
|
||
}
|
||
|
||
function clickOnTab(boardName) {
|
||
switchView(boardName);
|
||
}
|
||
|
||
// === EVENT HOKS ===
|
||
|
||
function bindBoardEvents() {
|
||
const boardSection = document.getElementById('board-section');
|
||
|
||
boardSection.addEventListener('click', function (event) {
|
||
const clickedTask = event.target.closest('.task');
|
||
if (!clickedTask) return;
|
||
|
||
const taskId = clickedTask.getAttribute('data-id');
|
||
const foundTask = tasks.find(function (t) { return t.id === taskId; });
|
||
if (foundTask) {
|
||
openTaskModal(foundTask);
|
||
}
|
||
});
|
||
|
||
boardSection.addEventListener('keydown', function (event) {
|
||
const focusedTask = document.querySelector('.task:focus, .task.selected');
|
||
if (!focusedTask) return;
|
||
|
||
const taskId = focusedTask.getAttribute('data-id');
|
||
const task = tasks.find(function (t) { return t.id === taskId; });
|
||
if (!task) return;
|
||
|
||
if (event.key === 'Delete') {
|
||
event.preventDefault();
|
||
const confirmed = confirm('Task wirklich löschen?');
|
||
if (!confirmed) return;
|
||
const index = tasks.indexOf(task);
|
||
if (index !== -1) {
|
||
tasks.splice(index, 1);
|
||
}
|
||
saveState();
|
||
renderBoard();
|
||
showNotification('Gelöscht', 'err');
|
||
return;
|
||
}
|
||
|
||
if (event.ctrlKey && event.altKey) {
|
||
event.preventDefault();
|
||
moveTaskByKeyboard(taskId, event.key, task.status);
|
||
}
|
||
});
|
||
|
||
boardSection.addEventListener('dragstart', onTaskDragStart);
|
||
boardSection.addEventListener('dragover', onBoardDragOver);
|
||
boardSection.addEventListener('dragenter', onBoardDragEnter);
|
||
boardSection.addEventListener('dragleave', onBoardDragLeave);
|
||
boardSection.addEventListener('drop', onBoardDrop);
|
||
}
|
||
|
||
let boardEventsBound = false;
|
||
if (!boardEventsBound) {
|
||
bindBoardEvents();
|
||
boardEventsBound = true;
|
||
}
|
||
|
||
// === BOARD RENDERING ===
|
||
|
||
function renderBoard() {
|
||
const query = document.getElementById('query').value.toLowerCase();
|
||
const priorityFilter = document.getElementById('priority-filter').value;
|
||
|
||
let boardContent = '';
|
||
|
||
for (const column of COLUMNS) {
|
||
const columnId = column[1];
|
||
const filteredTasks = tasks.filter(function (task) {
|
||
if (task.status !== columnId) return false;
|
||
if (query && task.title.toLowerCase().indexOf(query) === -1) return false;
|
||
if (priorityFilter && task.priority !== priorityFilter) return false;
|
||
return true;
|
||
});
|
||
|
||
let taskHtml = '';
|
||
for (const task of filteredTasks) {
|
||
const priorityClass = task.priority === 'Hoch' ? 'high' : task.priority === 'Tief' ? 'low' : 'medium';
|
||
const hasDate = task.dueDate ? formatDateString(task.dueDate) : '';
|
||
|
||
taskHtml += '<div class="task" data-id="' + htmlEscape(task.id) +
|
||
'" draggable="true" tabindex="0" role="button" aria-label="Task ' + htmlEscape(task.title) + '">' +
|
||
'<h3>' + htmlEscape(task.title) + '</h3>' +
|
||
(task.description ? '<p>' + htmlEscape(task.description) + '</p>' : '') +
|
||
'<div class="meta">' +
|
||
'<span class="badge ' + priorityClass + '">' + htmlEscape(task.priority) + '</span>' +
|
||
(hasDate ? '<span>' + htmlEscape(hasDate) + '</span>' : '') +
|
||
'</div></div>';
|
||
}
|
||
|
||
boardContent += '<div class="column" data-status="' + columnId + '"><div class="column-head">' +
|
||
'<span>' + column[2] + ' ' + column[0] + '</span>' +
|
||
'<span class="task-count">' + filteredTasks.length + '</span></div>' +
|
||
taskHtml + '</div>';
|
||
}
|
||
|
||
document.getElementById('board').innerHTML = boardContent;
|
||
}
|
||
|
||
// === TASK MODAL ===
|
||
|
||
function openTaskModal(task) {
|
||
previousFocus = document.activeElement;
|
||
isEditing = !!task;
|
||
|
||
const title = task ? task.title : '';
|
||
const description = task ? task.description : '';
|
||
const priority = task ? task.priority : 'Mittel';
|
||
const status = task ? task.status : 'aktiv';
|
||
const dueField = task && task.dueDate ? task.dueDate.slice(0, 10) : '';
|
||
|
||
document.getElementById('modal-title').textContent = isEditing ? 'Task bearbeiten' : 'Neues Task';
|
||
document.getElementById('f-title').value = title;
|
||
document.getElementById('f-desc').value = description;
|
||
document.getElementById('f-priority').value = priority;
|
||
document.getElementById('f-due').value = dueField;
|
||
document.getElementById('f-delete').style.display = isEditing ? 'inline-flex' : 'none';
|
||
|
||
let optionsHtml = '';
|
||
for (const column of COLUMNS) {
|
||
optionsHtml += '<option' + (status === column[1] ? ' selected' : '') + '>' + htmlEscape(column[0]) + '</option>';
|
||
}
|
||
document.getElementById('f-status').innerHTML = optionsHtml;
|
||
|
||
document.getElementById('modal-bg').classList.add('open');
|
||
document.getElementById('f-title').focus();
|
||
|
||
bindModalHandlers(task);
|
||
}
|
||
|
||
function bindModalHandlers(task) {
|
||
const saveButton = document.getElementById('f-save');
|
||
const cancelButton = document.getElementById('f-cancel');
|
||
const deleteButton = document.getElementById('f-delete');
|
||
|
||
saveButton.replaceWith(saveButton.cloneNode(true));
|
||
cancelButton.replaceWith(cancelButton.cloneNode(true));
|
||
deleteButton.replaceWith(deleteButton.cloneNode(true));
|
||
|
||
const newSave = document.getElementById('f-save');
|
||
const newCancel = document.getElementById('f-cancel');
|
||
const newDelete = document.getElementById('f-delete');
|
||
|
||
newSave.addEventListener('click', function () {
|
||
const newTitle = document.getElementById('f-title').value.trim();
|
||
if (!newTitle) {
|
||
showNotification('Bitte Titel eingeben', 'err');
|
||
document.getElementById('f-title').focus();
|
||
return;
|
||
}
|
||
|
||
const newDescription = document.getElementById('f-desc').value.trim();
|
||
const newPriority = document.getElementById('f-priority').value;
|
||
const newStatus = document.getElementById('f-status').value;
|
||
const newDueDate = document.getElementById('f-due').value
|
||
? new Date(document.getElementById('f-due').value + 'T00:00:00').toISOString()
|
||
: null;
|
||
|
||
if (isEditing) {
|
||
const existing = tasks.find(function (t) { return t.id === task.id; });
|
||
if (existing) {
|
||
existing.title = newTitle;
|
||
existing.description = newDescription;
|
||
existing.priority = newPriority;
|
||
existing.status = newStatus;
|
||
existing.dueDate = newDueDate;
|
||
if (newStatus === 'fertig' && !existing.finishedAt) {
|
||
existing.finishedAt = Date.now();
|
||
}
|
||
}
|
||
} else {
|
||
tasks.push({
|
||
id: generateUniqueId(),
|
||
title: newTitle,
|
||
description: newDescription,
|
||
priority: newPriority,
|
||
dueDate: newDueDate,
|
||
createdAt: Date.now(),
|
||
status: newStatus,
|
||
finishedAt: newStatus === 'fertig' ? Date.now() : null,
|
||
});
|
||
}
|
||
|
||
saveState();
|
||
renderView();
|
||
closeTaskModal();
|
||
showNotification('Gespeichert', 'grn');
|
||
});
|
||
|
||
newCancel.addEventListener('click', closeTaskModal);
|
||
|
||
newDelete.addEventListener('click', function () {
|
||
if (!confirm('Task wirklich löschen?')) return;
|
||
const indexOf = tasks.findIndex(function (t) { return t.id === task.id; });
|
||
if (indexOf !== -1) tasks.splice(indexOf, 1);
|
||
saveState();
|
||
renderView();
|
||
closeTaskModal();
|
||
showNotification('Gelöscht', 'err');
|
||
});
|
||
}
|
||
|
||
function closeTaskModal() {
|
||
document.getElementById('modal-bg').classList.remove('open');
|
||
if (previousFocus) {
|
||
try { previousFocus.focus(); } catch (e) { }
|
||
}
|
||
}
|
||
|
||
// === STREAKS ===
|
||
|
||
function renderStreaks() {
|
||
const gridContainer = document.getElementById('streak-grid');
|
||
|
||
if (habits.length === 0) {
|
||
gridContainer.innerHTML = '<p>Keine Habits. Es gibt keine Streak-Daten.</p>';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
for (const habit of habits) {
|
||
const currentStreak = calculateCurrentStreak(habit);
|
||
const longestStreak = calculateLongestStreak(habit);
|
||
|
||
html += '<div class="streak"><div class="streak-header"><h3>' +
|
||
htmlEscape(habit.name) +
|
||
'</h3><div><span class="streak-current">Aktuell: ' + currentStreak + '</span> | ' +
|
||
'<span class="streak-longest">Längste: ' + longestStreak + '</span></div></div>' +
|
||
'<div class="streak-grid">';
|
||
|
||
for (let i = 0; i < 26; i++) {
|
||
const day = new Date();
|
||
day.setDate(day.getDate() - i);
|
||
const dateKey = day.toISOString().slice(0, 10);
|
||
const value = habit.values ? habit.values[dateKey] || 0 : 0;
|
||
|
||
html += '<div class="streak-cell" data-value="' + value + '" data-date="' + dateKey + '" tabindex="0" role="button" aria-label="' + formatDateString(dateKey) + ', Level ' + value + '"></div>';
|
||
}
|
||
|
||
html += '</div></div>';
|
||
}
|
||
|
||
gridContainer.innerHTML = html;
|
||
|
||
gridContainer.addEventListener('click', function (event) {
|
||
const cell = event.target.closest('.streak-cell');
|
||
if (!cell) return;
|
||
|
||
const currentValue = parseInt(cell.getAttribute('data-value'), 10);
|
||
const nextValue = (currentValue + 1) % 5;
|
||
cell.setAttribute('data-value', nextValue);
|
||
|
||
const habitName = cell.closest('.streak').querySelector('h3').textContent;
|
||
const habit = habits.find(function (h) { return h.name === habitName; });
|
||
|
||
if (habit && !habit.values) habit.values = {};
|
||
if (habit) {
|
||
habit.values[cell.getAttribute('data-date')] = nextValue;
|
||
saveState();
|
||
renderStreaks();
|
||
}
|
||
});
|
||
}
|
||
|
||
function calculateCurrentStreak(habit) {
|
||
if (!habit.values) return 0;
|
||
let streak = 0;
|
||
const today = new Date();
|
||
for (let i = 0; i < 90; i++) {
|
||
const day = new Date(today);
|
||
day.setDate(day.getDate() - i);
|
||
const key = day.toISOString().slice(0, 10);
|
||
const val = habit.values[key];
|
||
if (val === undefined || val === 0) break;
|
||
streak++;
|
||
}
|
||
return streak;
|
||
}
|
||
|
||
function calculateLongestStreak(habit) {
|
||
if (!habit.values) return 0;
|
||
let longest = 0;
|
||
let current = 0;
|
||
const today = new Date();
|
||
for (let i = 0; i < 365; i++) {
|
||
const day = new Date(today);
|
||
day.setDate(day.getDate() - i);
|
||
const key = day.toISOString().slice(0, 10);
|
||
const val = habit.values[key];
|
||
if (val > 0) {
|
||
current++;
|
||
longest = Math.max(longest, current);
|
||
} else {
|
||
current = 0;
|
||
}
|
||
}
|
||
return longest;
|
||
}
|
||
|
||
// === INSIGHTS (2 Charts) ===
|
||
|
||
function renderInsights() {
|
||
const totalTasks = tasks.length;
|
||
let completed = 0;
|
||
let active = 0;
|
||
let overdue = 0;
|
||
|
||
for (const task of tasks) {
|
||
if (task.status === 'fertig') completed++;
|
||
else if (task.status === 'aktiv') active++;
|
||
else if (task.dueDate && new Date(task.dueDate) < new Date()) overdue++;
|
||
}
|
||
|
||
document.getElementById('insights-stats').innerHTML =
|
||
'<div class="stat-card"><div class="value">' + totalTasks + '</div><div class="label">Alle</div></div>' +
|
||
'<div class="stat-card"><div class="value" style="color:var(--green)">' + completed + '</div><div class="label">Erledigt</div></div>' +
|
||
'<div class="stat-card"><div class="value">' + active + '</div><div class="label">Aktiv</div></div>' +
|
||
'<div class="stat-card"><div class="value" style="color:var(--red)">' + overdue + '</div><div class="label">Überfällig</div></div>';
|
||
|
||
drawTrendChart();
|
||
drawDonutChart();
|
||
}
|
||
|
||
function drawTrendChart() {
|
||
const canvas = document.getElementById('trend-chart');
|
||
if (!canvas) return;
|
||
|
||
const ctx = canvas.getContext('2d');
|
||
canvas.width = canvas.parentElement.offsetWidth || 700;
|
||
canvas.height = 200;
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
|
||
const now = new Date();
|
||
const weekData = [];
|
||
|
||
for (let w = 0; w < 12; w++) {
|
||
const weekStart = new Date(now);
|
||
weekStart.setDate(weekStart.getDate() - (w * 7) - weekStart.getDay() + 1);
|
||
weekStart.setHours(0, 0, 0, 0);
|
||
|
||
const weekEnd = new Date(weekStart);
|
||
weekEnd.setDate(weekEnd.getDate() + 6);
|
||
weekEnd.setHours(23, 59, 59, 999);
|
||
|
||
let count = 0;
|
||
for (const task of tasks) {
|
||
if (!task.finishedAt) continue;
|
||
const finished = new Date(task.finishedAt);
|
||
if (finished >= weekStart && finished <= weekEnd) {
|
||
count++;
|
||
}
|
||
}
|
||
|
||
const label = (weekStart.getMonth() + 1) + '/' + w;
|
||
weekData.push({ label: label, count: count });
|
||
}
|
||
weekData.reverse();
|
||
|
||
const maxCount = Math.max(1, ...weekData.map(function (d) { return d.count; }));
|
||
|
||
const marginLeft = 35, marginRight = 10, marginTop = 10, marginBottom = 30;
|
||
const graphWidth = canvas.width - marginLeft - marginRight;
|
||
const graphHeight = canvas.height - marginTop - marginBottom;
|
||
const barWidth = 30;
|
||
const gap = (graphWidth / weekData.length - barWidth) * 0.5;
|
||
|
||
ctx.strokeStyle = 'rgba(0,0,0,0.15)';
|
||
ctx.lineWidth = 1;
|
||
for (let i = 0; i <= 4; i++) {
|
||
const y = marginTop + graphHeight - (i / 4) * graphHeight;
|
||
ctx.beginPath();
|
||
ctx.moveTo(marginLeft, y);
|
||
ctx.lineTo(canvas.width - marginRight, y);
|
||
ctx.stroke();
|
||
|
||
ctx.fillStyle = '#6e6e73';
|
||
ctx.font = '10px sans-serif';
|
||
ctx.textAlign = 'right';
|
||
ctx.fillText(Math.round(maxCount / 4 * i), marginLeft - 5, y + 4);
|
||
}
|
||
|
||
ctx.fillStyle = '#0071e3';
|
||
for (let i = 0; i < weekData.length; i++) {
|
||
const x = marginLeft + i * (barWidth / 1.5 + gap / 2);
|
||
const height = Math.max((weekData[i].count / maxCount) * graphHeight, 1);
|
||
const y = marginTop + graphHeight - height;
|
||
|
||
ctx.fillRect(x, y, barWidth, height);
|
||
|
||
ctx.fillStyle = '#6e6e73';
|
||
ctx.textAlign = 'center';
|
||
ctx.font = '9px sans-serif';
|
||
ctx.fillText(weekData[i].label, x + barWidth / 2, canvas.height - marginBottom + 12);
|
||
|
||
if (weekData[i].count > 0) {
|
||
ctx.fillStyle = '#1d1d1f';
|
||
ctx.fillText(weekData[i].count, x + barWidth / 2, y - 4);
|
||
}
|
||
}
|
||
}
|
||
|
||
function drawDonutChart() {
|
||
const canvas = document.getElementById('donut-chart');
|
||
if (!canvas) return;
|
||
|
||
const ctx = canvas.getContext('2d');
|
||
canvas.width = canvas.parentElement.offsetWidth || 400;
|
||
canvas.height = 200;
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
|
||
const counts = { 'Hoch': 0, 'Mittel': 0, 'Tief': 0 };
|
||
for (const task of tasks) {
|
||
if (counts[task.priority] !== undefined) {
|
||
counts[task.priority]++;
|
||
}
|
||
}
|
||
|
||
const total = tasks.length || 1;
|
||
const centerX = canvas.width / 2;
|
||
const centerY = canvas.height / 2;
|
||
const radius = Math.min(centerX, centerY) - 20;
|
||
const innerRadius = radius * 0.6;
|
||
|
||
const colors = { 'Hoch': '#ff3b30', 'Mittel': '#ffcc00', 'Tief': '#34c759' };
|
||
let startAngle = -Math.PI / 2;
|
||
|
||
for (const key of Object.keys(counts)) {
|
||
const slice = (counts[key] / total) * 2 * Math.PI;
|
||
if (slice > 0) {
|
||
ctx.beginPath();
|
||
ctx.arc(centerX, centerY, radius, startAngle, startAngle + slice);
|
||
ctx.arc(centerX, centerY, innerRadius, startAngle + slice, startAngle, true);
|
||
ctx.closePath();
|
||
ctx.fillStyle = colors[key];
|
||
ctx.fill();
|
||
}
|
||
startAngle += slice;
|
||
}
|
||
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.beginPath();
|
||
ctx.arc(centerX, centerY, innerRadius, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
const legendEl = document.getElementById('donut-legend');
|
||
if (legendEl) {
|
||
legendEl.innerHTML = '';
|
||
for (const key of Object.keys(counts)) {
|
||
const legendItem = document.createElement('span');
|
||
legendItem.innerHTML = '<span class="color-dot" style="background:' + colors[key] + '"></span> ' + htmlEscape(key) + ' (' + counts[key] + ')';
|
||
legendEl.appendChild(legendItem);
|
||
}
|
||
}
|
||
}
|
||
|
||
// === COMMAND PALETTE ===
|
||
|
||
function registerPalette() {
|
||
const commands = [
|
||
{ name: '📅 Board', shortcut: 'Cmd+K→B', action: function () { switchView('board'); } },
|
||
{ name: '🔥 Streaks', shortcut: 'Cmd+K→S', action: function () { switchView('streaks'); } },
|
||
{ name: '📊 Insights', shortcut: 'Cmd+K→I', action: function () { switchView('insights'); } },
|
||
{ name: '➕ Neues Task', shortcut: 'Cmd+K→N', action: function () { openTaskModal(null); } },
|
||
{ name: '📥 Importieren', shortcut: 'Cmd+K→M', action: function () { document.getElementById('imp').click(); } },
|
||
{ name: '📤 Exportieren', shortcut: 'Cmd+K→E', action: function () { doExport(); } },
|
||
{ name: '🗑️ Demo löschen', shortcut: 'Cmd+K→R', action: function () {
|
||
tasks.length = 0; habits.length = 0; saveState(); renderView(); showNotification('Zurückgesetzt', 'blu');
|
||
} },
|
||
{ name: '⚡ 500 Tasks generieren', shortcut: 'Cmd+K→5', action: function () { generate500Tasks(); } },
|
||
];
|
||
|
||
const recentTasks = tasks.slice(-5).reverse();
|
||
for (const task of recentTasks) {
|
||
commands.push({
|
||
name: '✏️ ' + task.title,
|
||
shortcut: '',
|
||
action: function (t) { openTaskModal(t); }.bind(null, task),
|
||
});
|
||
}
|
||
|
||
paletteCommand = commands;
|
||
}
|
||
|
||
function buildPaletteList() {
|
||
paletteIndex = 0;
|
||
let html = '';
|
||
|
||
for (let i = 0; i < paletteCommand.length; i++) {
|
||
html += '<div class="palette-item" data-index="' + i + '">' +
|
||
'<span class="name">' + htmlEscape(paletteCommand[i].name) +
|
||
'</span><span class="shortcut">' + htmlEscape(paletteCommand[i].shortcut) + '</span></div>';
|
||
}
|
||
|
||
document.getElementById('pal-list').innerHTML = html;
|
||
|
||
const items = document.querySelectorAll('#pal-list .palette-item');
|
||
for (const item of items) {
|
||
item.addEventListener('mouseenter', function () {
|
||
paletteIndex = parseInt(this.getAttribute('data-index'), 10);
|
||
highlightPaletteIndex();
|
||
});
|
||
|
||
item.addEventListener('click', function () {
|
||
const command = paletteCommand[parseInt(this.getAttribute('data-index'), 10)];
|
||
if (command) command.action();
|
||
});
|
||
}
|
||
}
|
||
|
||
function highlightPaletteIndex() {
|
||
const items = document.querySelectorAll('#pal-list .palette-item');
|
||
for (let i = 0; i < items.length; i++) {
|
||
items[i].classList.toggle('selected', i === paletteIndex);
|
||
}
|
||
}
|
||
|
||
function filterPaletteItems() {
|
||
const query = document.getElementById('pal-q').value.toLowerCase();
|
||
const items = document.querySelectorAll('#pal-list .palette-item');
|
||
|
||
for (const item of items) {
|
||
const name = item.querySelector('.name').textContent.toLowerCase();
|
||
item.style.display = name.indexOf(query) === -1 ? 'none' : 'flex';
|
||
}
|
||
}
|
||
|
||
function openPalette() {
|
||
document.getElementById('pal-bg').classList.add('open');
|
||
document.getElementById('pal-q').value = '';
|
||
document.getElementById('pal-q').focus();
|
||
registerPalette();
|
||
buildPaletteList();
|
||
}
|
||
|
||
function closePalette() {
|
||
document.getElementById('pal-bg').classList.remove('open');
|
||
}
|
||
|
||
document.querySelector('.palette-background').addEventListener('click', function (event) {
|
||
if (event.target.id === 'pal-bg') {
|
||
closePalette();
|
||
}
|
||
});
|
||
|
||
document.addEventListener('keydown', function (event) {
|
||
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
|
||
event.preventDefault();
|
||
openPalette();
|
||
return;
|
||
}
|
||
|
||
if (event.key === 'Escape') {
|
||
closeTaskModal();
|
||
setTimeout(closePalette, 50);
|
||
}
|
||
});
|
||
|
||
document.getElementById('pal-q').addEventListener('input', function () {
|
||
buildPaletteList();
|
||
filterPaletteItems();
|
||
});
|
||
|
||
document.getElementById('pal-q').addEventListener('keydown', function (event) {
|
||
const visibleItems = document.querySelectorAll('#pal-list .palette-item:not([style*="display: none"])');
|
||
if (!visibleItems.length) return;
|
||
|
||
if (event.key === 'ArrowDown') {
|
||
event.preventDefault();
|
||
const allItems = document.querySelectorAll('#pal-list .palette-item');
|
||
const indices = [];
|
||
for (const item of allItems) {
|
||
if (item.style.display !== 'none') {
|
||
indices.push(parseInt(item.getAttribute('data-index'), 10));
|
||
}
|
||
}
|
||
const pos = indices.indexOf(paletteIndex);
|
||
const next = pos >= indices.length - 1 ? indices[0] : indices[pos + 1];
|
||
paletteIndex = next;
|
||
highlightPaletteIndex();
|
||
} else if (event.key === 'ArrowUp') {
|
||
event.preventDefault();
|
||
const allItems = document.querySelectorAll('#pal-list .palette-item');
|
||
const indices = [];
|
||
for (const item of allItems) {
|
||
if (item.style.display !== 'none') {
|
||
indices.push(parseInt(item.getAttribute('data-index'), 10));
|
||
}
|
||
}
|
||
const pos = indices.indexOf(paletteIndex);
|
||
const prev = pos <= 0 ? indices[indices.length - 1] : indices[pos - 1];
|
||
paletteIndex = prev;
|
||
highlightPaletteIndex();
|
||
} else if (event.key === 'Enter') {
|
||
const selected = document.querySelector('#pal-list .palette-item.selected');
|
||
if (selected) selected.click();
|
||
} else if (event.key === 'Escape') {
|
||
closePalette();
|
||
} else {
|
||
return;
|
||
}
|
||
});
|
||
|
||
// === DRAG & DROP ===
|
||
|
||
function onTaskDragStart(event) {
|
||
const task = event.target.closest('.task');
|
||
if (!task) return;
|
||
|
||
event.stopPropagation();
|
||
dragSourceId = task.getAttribute('data-id');
|
||
event.dataTransfer.effectAllowed = 'move';
|
||
event.dataTransfer.setData('text/plain', dragSourceId);
|
||
task.classList.add('dragging');
|
||
|
||
event.target.addEventListener('dragend', onTaskDragEnd);
|
||
}
|
||
|
||
function onTaskDragEnd(event) {
|
||
const task = event.target;
|
||
task.classList.remove('dragging');
|
||
task.removeEventListener('dragend', onTaskDragEnd);
|
||
}
|
||
|
||
function onBoardDragOver(event) {
|
||
event.preventDefault();
|
||
event.dataTransfer.dropEffect = 'move';
|
||
}
|
||
|
||
function onBoardDragEnter(event) {
|
||
event.preventDefault();
|
||
const column = event.target.closest('.column');
|
||
if (column) column.classList.add('drag-over');
|
||
}
|
||
|
||
function onBoardDragLeave(event) {
|
||
const column = event.target.closest('.column');
|
||
if (column && !column.contains(event.relatedTarget)) {
|
||
column.classList.remove('drag-over');
|
||
}
|
||
}
|
||
|
||
function onBoardDrop(event) {
|
||
event.preventDefault();
|
||
dragSourceId = event.dataTransfer.getData('text/plain');
|
||
|
||
document.querySelectorAll('.column').forEach(function (col) {
|
||
col.classList.remove('drag-over');
|
||
});
|
||
|
||
if (!dragSourceId) return;
|
||
|
||
const task = tasks.find(function (t) { return t.id === dragSourceId; });
|
||
if (!task) return;
|
||
|
||
const targetColumn = event.target.closest('.column');
|
||
if (!targetColumn) return;
|
||
|
||
const targetStatus = targetColumn.getAttribute('data-status');
|
||
if (targetStatus === task.status) {
|
||
const targetTask = event.target.closest('.task');
|
||
if (targetTask && targetTask.getAttribute('data-id')) {
|
||
const targetId = targetTask.getAttribute('data-id');
|
||
if (targetId === task.id) return;
|
||
|
||
const fromIndex = tasks.findIndex(function (t) { return t.id === targetId; });
|
||
const toIndex = tasks.findIndex(function (t) { return t.id === task.id; });
|
||
if (fromIndex !== -1 && toIndex !== -1 && fromIndex > toIndex) {
|
||
tasks.splice(toIndex, 1);
|
||
tasks.splice(fromIndex, 0, task);
|
||
}
|
||
}
|
||
} else {
|
||
const fromIndex = tasks.indexOf(task);
|
||
if (fromIndex !== -1) tasks.splice(fromIndex, 1);
|
||
|
||
task.status = targetStatus;
|
||
if (targetStatus === 'fertig') task.finishedAt = Date.now();
|
||
|
||
tasks.push(task);
|
||
}
|
||
|
||
saveState();
|
||
renderView();
|
||
showNotification('Gezogen zu ' + targetStatus, 'grn');
|
||
}
|
||
|
||
// === TASTATUR-DAG & DROP ===
|
||
|
||
function moveTaskByKeyboard(taskId, arrowKey, currentStatus) {
|
||
const columnIndex = COLUMNS.findIndex(function (c) { return c[1] === currentStatus; });
|
||
const task = tasks.find(function (t) { return t.id === taskId; });
|
||
if (!task) return;
|
||
|
||
if (arrowKey === 'ArrowLeft' || arrowKey === 'ArrowRight') {
|
||
const newStatusIndex = (arrowKey === 'ArrowRight')
|
||
? Math.min(columnIndex + 1, COLUMNS.length - 1)
|
||
: Math.max(columnIndex - 1, 0);
|
||
|
||
task.status = COLUMNS[newStatusIndex][1];
|
||
if (task.status === 'fertig') task.finishedAt = Date.now();
|
||
else task.finishedAt = null;
|
||
|
||
const oldIndex = tasks.indexOf(task);
|
||
if (oldIndex !== -1) tasks.splice(oldIndex, 1);
|
||
tasks.push(task);
|
||
|
||
showNotification('Spalte: ' + COLUMNS[newStatusIndex][0], 'grn');
|
||
} else if (arrowKey === 'ArrowUp' || arrowKey === 'ArrowDown') {
|
||
const spaltenTasks = tasks.filter(function (t) { return t.status === currentStatus; });
|
||
const currentIndex = spaltenTasks.findIndex(function (t) { return t.id === taskId; });
|
||
|
||
const newIndex = (arrowKey === 'ArrowDown')
|
||
? Math.min(currentIndex + 1, spaltenTasks.length - 1)
|
||
: Math.max(currentIndex - 1, 0);
|
||
|
||
const targetId = spaltenTasks[newIndex].id;
|
||
|
||
const taskOldIndex = tasks.indexOf(task);
|
||
if (taskOldIndex !== -1) tasks.splice(taskOldIndex, 1);
|
||
|
||
const newTaskIndex = tasks.findIndex(function (t) { return t.id === targetId; });
|
||
tasks.splice(newTaskIndex, 0, task);
|
||
|
||
const newFocused = document.querySelector('.task[data-id="' + taskId + '"]');
|
||
if (newFocused) newFocused.focus();
|
||
}
|
||
|
||
saveState();
|
||
renderView();
|
||
announceStatus('Task verschoben');
|
||
}
|
||
|
||
// === EXPORT / IMPORT ===
|
||
|
||
function doExport() {
|
||
const exportData = {
|
||
version: 1,
|
||
tasks: tasks,
|
||
habits: habits,
|
||
theme: currentTheme,
|
||
exportedAt: new Date().toISOString(),
|
||
};
|
||
|
||
const json = JSON.stringify(exportData, null, 2);
|
||
const blob = new Blob([json], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const anchor = document.createElement('a');
|
||
anchor.href = url;
|
||
anchor.download = 'nova-export-' + new Date().toISOString().slice(0, 10) + '.json';
|
||
document.body.appendChild(anchor);
|
||
anchor.click();
|
||
document.body.removeChild(anchor);
|
||
URL.revokeObjectURL(url);
|
||
|
||
showNotification('Export OK', 'grn');
|
||
}
|
||
|
||
function doImport(file) {
|
||
if (!file) return;
|
||
|
||
const reader = new FileReader();
|
||
reader.onload = function (event) {
|
||
try {
|
||
const imported = JSON.parse(event.target.result);
|
||
|
||
if (!imported || typeof imported !== 'object') {
|
||
throw new Error('keine JSON-Struktur');
|
||
}
|
||
if (imported.version !== 1 && imported.version !== undefined) {
|
||
// Alte Version, akzeptiert
|
||
}
|
||
if (!imported.tasks || !Array.isArray(imported.tasks)) {
|
||
throw new Error('tasks fehlt oder ist kein Array');
|
||
}
|
||
|
||
const validTasks = [];
|
||
const invalidCount = [];
|
||
for (const task of imported.tasks) {
|
||
if (!task.id || !task.title || !task.status) {
|
||
invalidCount.push(task.title || task.id);
|
||
continue;
|
||
}
|
||
validTasks.push({
|
||
id: task.id,
|
||
title: task.title,
|
||
description: task.description || '',
|
||
priority: task.priority || 'Mittel',
|
||
status: task.status,
|
||
dueDate: task.dueDate || task.d || null,
|
||
createdAt: task.createdAt || task.dt || Date.now(),
|
||
finishedAt: task.finishedAt || task.df || null,
|
||
});
|
||
}
|
||
|
||
if (validTasks.length === 0) {
|
||
throw new Error('Keine gültigen Tasks gefunden');
|
||
}
|
||
|
||
tasks.splice(0, tasks.length, ...validTasks);
|
||
|
||
if (imported.habits && Array.isArray(imported.habits)) {
|
||
habits.splice(0, habits.length, ...imported.habits);
|
||
}
|
||
if (imported.theme) {
|
||
currentTheme = imported.theme;
|
||
applyTheme();
|
||
}
|
||
|
||
saveState();
|
||
registerPalette();
|
||
renderView();
|
||
showNotification('Import OK (' + validTasks.length + ' Tasks, ' + invalidCount.length + ' ignoriert)', 'grn');
|
||
|
||
if (invalidCount.length > 0) {
|
||
alert('Einige Tasks wurden nicht importiert (fehlende id/Title/Status): ' + invalidCount.join(', '));
|
||
}
|
||
|
||
} catch (error) {
|
||
showNotification('Import fehlgeschlagen: ' + error.message, 'err');
|
||
}
|
||
};
|
||
reader.readAsText(file);
|
||
}
|
||
|
||
document.getElementById('imp').addEventListener('change', function (event) {
|
||
const file = event.target.files[0];
|
||
if (file) doImport(file);
|
||
event.target.value = '';
|
||
});
|
||
|
||
// === DEMO-DATEN ===
|
||
|
||
let demoLoaded = false;
|
||
|
||
function loadDemoData() {
|
||
if (demoLoaded && tasks.length > 0) return;
|
||
demoLoaded = true;
|
||
|
||
const titles = [
|
||
'Dashboard', 'API', 'Cache', 'Logging', 'Tests', 'Docs', 'CI/CD', 'Auth', 'Login', 'Panel',
|
||
'Widget', 'Chart', 'Setup', 'Deploy', 'Monitor', 'Alert', 'Backup', 'Config', 'Schema', 'Migration',
|
||
'Refactor', 'Bugfix', 'Feature', 'Story', 'Epic', 'Release', 'Hotfix', 'Review', 'Sprint', 'Retro',
|
||
];
|
||
|
||
for (let i = 0; i < 40; i++) {
|
||
const status = ['backlog', 'aktiv', 'review', 'fertig'][i % 4];
|
||
const priority = ['Hoch', 'Tief', 'Mittel', 'Mittel', 'Tief'][i % 5];
|
||
const dueDate = new Date(Date.now() + i * 864e5).toISOString();
|
||
|
||
tasks.push({
|
||
id: generateUniqueId(),
|
||
title: titles[i] + ' #' + (i + 1),
|
||
description: 'Beschreibung für ' + titles[i] + ' #' + (i + 1),
|
||
priority: priority,
|
||
dueDate: dueDate,
|
||
createdAt: Date.now() - i * 864e5,
|
||
status: status,
|
||
finishedAt: status === 'fertig' ? Date.now() : null,
|
||
});
|
||
}
|
||
|
||
const habitDefaults = [
|
||
{ name: 'Code', values: generateHabitValues(15) },
|
||
{ name: 'Sport', values: generateHabitValues(10) },
|
||
{ name: 'Lesen', values: generateHabitValues(8) },
|
||
{ name: 'Meditation', values: generateHabitValues(12) },
|
||
];
|
||
habits.length = 0;
|
||
habits.push(...habitDefaults);
|
||
|
||
saveState();
|
||
}
|
||
|
||
function generateHabitValues(days) {
|
||
const values = {};
|
||
for (let i = 0; i < days; i++) {
|
||
const day = new Date();
|
||
day.setDate(day.getDate() - i);
|
||
values[day.toISOString().slice(0, 10)] = Math.random() < 0.65 ? Math.floor(Math.random() * 4) + 1 : 0;
|
||
}
|
||
return values;
|
||
}
|
||
|
||
// === 500 TASKS GENERIEREN (Performance: Batch) ===
|
||
|
||
function generate500Tasks() {
|
||
const batch = [];
|
||
for (let i = 0; i < 500; i++) {
|
||
const status = ['backlog', 'aktiv', 'review', 'fertig'][i % 4];
|
||
const priority = ['Hoch', 'Tief', 'Mittel', 'Mittel', 'Tief'][i % 5];
|
||
const title = 'Batch Task #' + (i + 1);
|
||
|
||
batch.push({
|
||
id: generateUniqueId(),
|
||
title: title,
|
||
description: 'Automatisch generierter Task #' + (i + 1),
|
||
priority: priority,
|
||
dueDate: new Date(Date.now() + i * 1728e5).toISOString(),
|
||
createdAt: Date.now() - i * 1728e5,
|
||
status: status,
|
||
finishedAt: status === 'fertig' ? Date.now() : null,
|
||
});
|
||
}
|
||
|
||
tasks.splice(0, 0, ...batch);
|
||
saveState();
|
||
renderView();
|
||
showNotification('500 Tasks generiert', 'grn');
|
||
}
|
||
|
||
// === HASH-ROUTING ===
|
||
|
||
function setupHashRouting() {
|
||
const views = ['board', 'streaks', 'insights'];
|
||
|
||
function handleHashChange() {
|
||
const hash = location.hash.replace('#/', '');
|
||
if (views.includes(hash)) {
|
||
currentView = hash;
|
||
const navButtons = document.querySelectorAll('.sidebar nav button');
|
||
for (const button of navButtons) {
|
||
button.classList.toggle('selected', button.getAttribute('data-view') === currentView);
|
||
}
|
||
const sections = document.querySelectorAll('.section');
|
||
for (const section of sections) {
|
||
section.classList.toggle('visible', section.id === currentView + '-section');
|
||
}
|
||
renderView();
|
||
}
|
||
}
|
||
|
||
window.addEventListener('hashchange', handleHashChange);
|
||
handleHashChange();
|
||
}
|
||
|
||
setupHashRouting();
|
||
|
||
// === SIDEBAR-CLICK EVENTS ===
|
||
|
||
document.querySelectorAll('.sidebar button[data-view]').forEach(function (button) {
|
||
button.addEventListener('click', function () {
|
||
const viewName = button.getAttribute('data-view');
|
||
switchView(viewName);
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('.theme-btns button').forEach(function (button) {
|
||
button.addEventListener('click', function () {
|
||
currentTheme = button.getAttribute('data-theme-value');
|
||
applyTheme();
|
||
saveState();
|
||
renderView();
|
||
});
|
||
});
|
||
|
||
document.getElementById('btn-new').addEventListener('click', function () {
|
||
openTaskModal(null);
|
||
});
|
||
|
||
const queryInput = document.getElementById('query');
|
||
const prioritySelect = document.getElementById('priority-filter');
|
||
|
||
let queryRenderTimeout = null;
|
||
queryInput.addEventListener('input', function () {
|
||
clearTimeout(queryRenderTimeout);
|
||
queryRenderTimeout = setTimeout(renderBoard, 150);
|
||
});
|
||
prioritySelect.addEventListener('change', renderBoard);
|
||
|
||
// === APP START ===
|
||
|
||
loadState();
|
||
applyTheme();
|
||
subscribeToSystemThemeChange();
|
||
|
||
if (tasks.length === 0 && habits.length === 0) {
|
||
loadDemoData();
|
||
}
|
||
|
||
renderView();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|