Files
nova-control/index.html
T
Dann Kra 742d56e746 refactor: Complete rewrite - all 18 gates fixed, readable names, const/let, semantic HTML, dark mode, DnD, charts, exports, hash routing, responsive, reduced motion, focus trap, 500 tasks, robustness
Gates fixed:
P0: P0.1 file dialog removed, P0.2 palette registered, P0.3 dark mode
P1: P1.1 mouse drag & drop, P1.2 keyboard DnD, P1.3 du/d unified, P1.4 streak calc, P1.5 export/import, P1.6 chart2 + week trend, P1.7 listener leak
P2: P2.1 26-week heatmap, P2.2 responsive, P2.3 reduced-motion, P2.4 focus-trap, P2.5 palette keyboard, P2.6 hash routing, P2.7 500 tasks, P2.8 robustness
2026-06-28 22:27:41 +00:00

2060 lines
58 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<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;
--sidebar-width: 220px;
--modal-width: 460px;
--palette-width: 480px;
--col-gap: 12px;
}
[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: var(--sidebar-width) 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);
transition: background 0.15s ease;
}
.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: var(--col-gap);
}
.column {
background: var(--surface);
border-radius: 8px;
padding: 12px;
min-height: 350px;
transition: box-shadow 0.2s ease;
}
.column.drag-over {
box-shadow: 0 0 0 2px var(--accent);
}
.column.drag-over::after {
content: 'Drop';
display: block;
text-align: center;
padding: 10px;
color: var(--accent);
font-weight: 600;
border: 2px dashed var(--accent);
border-radius: 6px;
margin-top: 6px;
}
.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;
transition: border-color 0.15s ease, transform 0.15s ease;
}
.task:hover {
border-color: var(--accent);
}
.task:focus {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.task.dragging {
opacity: 0.5;
transform: scale(0.98);
}
.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: var(--modal-width);
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: var(--palette-width);
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-wrap {
width: 100%;
height: 200px;
}
.chart-wrap canvas {
width: 100%;
height: 100%;
}
.chart-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
}
.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: 8px;
height: 8px;
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-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;
}
.task {
transition: none !important;
}
.sidebar button, .theme-btns button {
transition: 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="Daten 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-wrap"><canvas id="trend-chart"></canvas></div>
</div>
<div class="chart">
<div class="chart-title">Prioritätsverteilung</div>
<div class="chart-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 ===
/** Erzeugt eine eindeutige ID */
function generateUniqueId() {
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).slice(2, 6);
return 'd' + timestamp + '_' + random;
}
/** Escape HTML-Entitäten für sichereres Renderen */
function htmlEscape(text) {
if (!text) return '';
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** Formatiert ein ISO-Datum als DD.MM.YYYY */
function formatDateString(isoDateString) {
if (!isoDateString) return '';
try {
return new Date(isoDateString).toLocaleDateString('de-DE');
} catch (error) {
return '';
}
}
/** Zeigt einen temporären Toast-Nachricht an */
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);
}
/** Aktualisiert die Live-Region für Screenreader */
function announceStatus(message) {
const liveRegion = document.getElementById('live-region');
if (liveRegion) {
liveRegion.textContent = message;
}
}
// === PERSISTENZ ===
/** Speichert Tasks, Habits und Theme in localStorage */
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
}
}
/** Lädt Tasks, Habits und Theme aus localStorage (robust mit Validierung) */
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 / DARK MODE ===
/** Wendet das aktuell gespeicherte Theme auf die Seite an */
function applyTheme() {
const mediaQuery = window.matchMedia('(prefers-color-scheme:dark)');
const documentElement = document.documentElement;
if (currentTheme === 'Systemeinstellung') {
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);
}
}
/** Hört auf Änderungen der Systemeinstellung und reagiert live */
function subscribeToSystemThemeChange() {
const mediaQuery = window.matchMedia('(prefers-color-scheme:dark)');
mediaQuery.addEventListener('change', function () {
if (currentTheme === 'Systemeinstellung') {
applyTheme();
}
});
}
// === NAVIGATION / VIEWS ===
/** Schaltet die aktive View um und aktualisiert Hash für Persistenz */
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();
}
/** Rendert die aktuelle View dynamisch */
function renderView() {
if (currentView === 'board') {
renderBoard();
} else if (currentView === 'streaks') {
renderStreaks();
} else if (currentView === 'insights') {
renderInsights();
}
}
/** Navigiert zum Board */
function clickOnTab(boardName) {
switchView(boardName);
}
// === EVENT-DELEGATION (Board) ===
/** Weist die Event-Listener NUR EINMAL dem Board-Container zu */
function bindBoardEvents() {
const boardSection = document.getElementById('board-section');
// Klick-Delegation auf ".task" (Edit-Modal öffnen)
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);
}
});
// Keyboard-Delegation für Löschen und Drag & Drop
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;
// Delete-Taste: Task löschen
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;
}
// Tastatur-DnD: Ctrl+Alt + Pfeil
if (event.ctrlKey && event.altKey) {
event.preventDefault();
moveTaskByKeyboard(taskId, event.key, task.st);
}
});
// Drag & Drop-Delegation
boardSection.addEventListener('dragstart', onTaskDragStart);
boardSection.addEventListener('dragover', onBoardDragOver);
boardSection.addEventListener('dragenter', onBoardDragEnter);
boardSection.addEventListener('dragleave', onBoardDragLeave);
boardSection.addEventListener('drop', onBoardDrop);
}
/** Löscht die alten Event-Listener und weist sie NUR EINMAL erneut zu */
function initBoardEventsOnce() {
if (initBoardEventsOnce.initialized) return;
initBoardEventsOnce.initialized = true;
bindBoardEvents();
}
initBoardEventsOnce();
// === BOARD RENDERING ===
/** Rendert das Board: Header + Toolbar + Spalten.
* Toolbar bleibt im HTML stabl, wird NICHT neu gebaut. */
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 (op())
// =========================
/** Öffnet das Modal für ein neues oder bearbeitendes Task. */
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 sinceField = 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 = sinceField;
document.getElementById('f-delete').style.display = isEditing ? 'inline-flex' : 'none';
// Status-Optionen aufbereitet
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();
// Einmaligen Event-Handler setzen (keine Leaks!)
bindModalHandlers(task);
}
/** Weist die Modal-Handler EINMAL zu verhindert doppelte Listener */
function bindModalHandlers(task) {
const saveButton = document.getElementById('f-save');
const cancelButton = document.getElementById('f-cancel');
const deleteButton = document.getElementById('f-delete');
// Alte Handler entfernen
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');
});
}
/** Schließt das Task-Modal */
function closeTaskModal() {
document.getElementById('modal-bg').classList.remove('open');
if (previousFocus) {
try { previousFocus.focus(); } catch (e) { /* Ignorieren */ }
}
}
// =========================
// STREAKS
// =========================
/** Rendert die Streak-Ansicht mit aktuellen + längsten Serie */
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"><h3>' + htmlEscape(habit.name) +
' — Aktuelle Serie: ' + currentStreak + ' | Längste Serie: ' + longestStreak + '</h3><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;
// Einmaligen Click-Handler auf das Grid (Delegation)
gridContainer.onclick = 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 habitNameOnly = habitName.replace(' — Aktuelle Serie: .*', '');
const habit = habits.find(function (h) { return h.name === habitNameOnly; });
if (habit && !habit.values) habit.values = {};
if (habit) {
habit.values[cell.getAttribute('data-date')] = nextValue;
saveState();
renderStreaks();
}
};
}
/** Berechnet die aktuelle Serie eines Habits (laufend seit dem letzten 0-Wert) */
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++;
}
// Heute noch nicht bewertet = kein Bruch, aber auch kein Zähl-Erwartung
return streak;
}
/** Berechnet die längste durchgehende Serie */
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 if (val === 0 || val === undefined) {
current = 0;
}
}
return longest;
}
// =========================
// INSIGHTS (2 Charts)
// =========================
/** Rendert Insights: Stats + Trend-Woche + Donut */
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();
}
/** Zieht Daten in Wochen-Intervallen aus finishedAt (df-Feld) */
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 = [];
// Letzte 12 Wochen
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;
// Gitter & Y-Achsen-Label
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);
}
// Balken
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);
// Label
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);
}
}
}
/** Zeichnet den Donut-Chart (Prioritätsverteilung) */
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;
}
// Zentrum
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(centerX, centerY, innerRadius, 0, Math.PI * 2);
ctx.fill();
// Legende
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
// =========================
/** Registriert die Befehle in der Palette (≥8 Einträge, inkl. Tasks als anspringbar) */
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(); } },
];
// Dynamisch Tasks als anspringbare Einträge ergänzen (max. 5)
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;
}
/** Baut die Palette-Liste aus den registrierten Befehlen auf */
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;
// Einmalige Event-Handler
const items = document.querySelectorAll('#pal-list .palette-item');
for (const item of items) {
// Hover-Indizierung
item.addEventListener('mouseenter', function () {
paletteIndex = parseInt(this.getAttribute('data-index'), 10);
highlightPaletteIndex();
});
// Klick führt Befehl aus
item.addEventListener('click', function () {
const command = paletteCommand[parseInt(this.getAttribute('data-index'), 10)];
if (command) command.action();
});
}
}
/** Markiert den aktuell selektierten Palette-Eintrag visuell */
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);
}
}
/** Filtert Palette-Einträge nach Suchbegriff */
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';
}
}
/** Öffnet die Command-Palette */
function openPalette() {
document.getElementById('pal-bg').classList.add('open');
document.getElementById('pal-q').value = '';
document.getElementById('pal-q').focus();
registerPalette();
buildPaletteList();
}
/** Schließt die Command-Palette */
function closePalette() {
document.getElementById('pal-bg').classList.remove('open');
}
// Klick auf Hintergrund schließt Palette
document.querySelector('.palette-background').addEventListener('click', function (event) {
if (event.target.id === 'pal-bg') {
closePalette();
}
});
// Globale Tasten-Kurzverbindungen
document.addEventListener('keydown', function (event) {
// Cmd+K oder Strg+K → Palette öffnen
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
event.preventDefault();
openPalette();
return;
}
// Escape → Modal + Palette schließen
if (event.key === 'Escape') {
closeTaskModal();
setTimeout(closePalette, 50);
}
});
// Palette-Input Listener (Suche + Pfeiltasten + Enter + Tab-Trap)
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 maxIndex = Math.min(paletteIndex + 1, visibleItems.length - 1);
// Finde die tatsächliche Position im vollständigen Array
let visibleCount = 0;
for (let i = 0; i < paletteIndex; i++) {
if (!paletteCommand[i].name) continue;
const domEl = document.querySelector('#pal-list .palette-item[data-index="' + i + '"]');
if (domEl && domEl.style.display !== 'none') visibleCount++;
}
paletteIndex = findNextVisibleIndex(paletteIndex, 1, visibleItems);
if (paletteIndex !== -1) highlightPaletteIndex();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
paletteIndex = findNextVisibleIndex(paletteIndex, -1, visibleItems);
if (paletteIndex !== -1) 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;
}
});
/** Hilft bei der Navigation durch sichtbare Palette-Einträge */
function findNextVisibleIndex(currentIndex, direction, visibleItems) {
const allItems = document.querySelectorAll('#pal-list .palette-item');
const visibleMap = new Map();
for (const item of allItems) {
if (item.style.display !== 'none') {
visibleMap.set(parseInt(item.getAttribute('data-index'), 10), true);
}
}
const indices = Array.from(allItems).map(function (el) { return parseInt(el.getAttribute('data-index'), 10); }).filter(function (i) {
return visibleMap.has(i);
});
const pos = indices.indexOf(currentIndex);
if (pos === -1) return direction > 0 ? indices[0] : indices[indices.length - 1];
const next = pos + direction;
if (next < 0) return indices[indices.length - 1];
if (next >= indices.length) return indices[0];
return indices[next];
}
// =========================
// DRAG & DROP
// =========================
/** Wird ausgelöst, wenn ein Task zum Draggen anfängt */
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);
}
/** Entfernt das dragging-Flag vom Task beim Ende eines DnD */
function onTaskDragEnd(event) {
const task = event.target;
task.classList.remove('dragging');
task.removeEventListener('dragend', onTaskDragEnd);
}
/** Erlaubt das Drop auf den Board-Container */
function onBoardDragOver(event) {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}
/** Hebt die Ziel-Spalte visuell hervor */
function onBoardDragEnter(event) {
event.preventDefault();
const column = event.target.closest('.column');
if (column) column.classList.add('drag-over');
}
/** Entfernt die Highlighting der Ziel-Spalte */
function onBoardDragLeave(event) {
const column = event.target.closest('.column');
if (column && !column.contains(event.relatedTarget)) {
column.classList.remove('drag-over');
}
}
/** Verarbeitet den Drop: Status ändern oder Reihenfolge anpassen */
function onBoardDrop(event) {
event.preventDefault();
dragSourceId = event.dataTransfer.getData('text/plain');
// Alles Highlighting aufräumen
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;
// Ziel-Spalte bestimmen
const targetColumn = event.target.closest('.column');
if (!targetColumn) return;
const targetStatus = targetColumn.getAttribute('data-status');
if (targetStatus === task.status) {
// Gleiche Spalte = Reihenfolge anpassen
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 {
// Neue Spalte = Status ändern
const fromIndex = tasks.indexOf(task);
if (fromIndex !== -1) tasks.splice(fromIndex, 1);
task.status = targetStatus;
if (targetStatus === 'fertig') task.finishedAt = Date.now();
// Am Ende der Spalte einfügen
tasks.push(task);
}
saveState();
renderView();
showNotification('Gezogen zu ' + targetStatus, 'grn');
}
// =========================
// TASTATUR-DRAG & DROP
// =========================
/** Bewegt ein fokussiertes Task per Tastatur (Ctrl+Alt+Pfeil) */
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') {
// Status-Spalte wechseln
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;
// Ans Ende der Spalte schieben
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') {
// Position in aktueller Spalte wechseln
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;
// Task aus Array auslagern, neu an position einfügen
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);
// Fokus auf neues Position-Element setzen
const newFocused = document.querySelector('.task[data-id="' + taskId + '"]');
if (newFocused) newFocused.focus();
}
saveState();
renderView();
announceStatus('Task verschoben');
}
// =========================
// EXPORT / IMPORT
// =========================
/** Exportiert Tasks, Habits und Theme als herabladbares JSON-Blob */
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');
}
/** Importiert Daten aus JSON-Datei mit Validierung */
function doImport(file) {
if (!file) return;
const reader = new FileReader();
reader.onload = function (event) {
try {
const imported = JSON.parse(event.target.result);
// Validierung: version + tasks Array
if (!imported || typeof imported !== 'object') {
throw new Error('keine JSON-Struktur');
}
if (imported.version !== 1 && imported.version !== undefined) {
// Nicht fatal: alte Dateien verarbeiten
}
if (!imported.tasks || !Array.isArray(imported.tasks)) {
throw new Error('tasks fehlt oder ist kein Array');
}
// Jeden Task validieren
const validTasks = [];
const invalidCount = [];
for (const task of imported.tasks) {
if (!task.id || !task.title || !task.status) {
invalidCount.push(task.title || task.id);
continue;
}
// Erweitere um bestehende Felder wenn vorhanden
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/titel/Status): ' + invalidCount.join(', '));
}
} catch (error) {
showNotification('Import fehlgeschlagen: ' + error.message, 'err');
}
};
reader.readAsText(file);
}
// File-Input für Import-Dialog
document.getElementById('imp').addEventListener('change', function (event) {
const file = event.target.files[0];
if (file) doImport(file);
event.target.value = ''; // Reset
});
// =========================
// DEMO-DATEN
// =========================
let demoLoaded = false;
/** Füllt die App mit Demo-Daten (wenn leer) */
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,
});
}
habits = [
{ name: 'Code', values: generateHabitValues(15) },
{ name: 'Sport', values: generateHabitValues(10) },
{ name: 'Lesen', values: generateHabitValues(8) },
{ name: 'Meditation', values: generateHabitValues(12) },
];
saveState();
}
/** Generiert zufällige Habit-Werte für n Tage */
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)
// =========================
/** Generiert 500 Tasks in einem Batch (kein Einzel-Push pro Task) */
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,
});
}
// Batch-Push statt Einzel-Push für Performance
tasks.splice(0, 0, ...batch);
saveState();
renderView();
showNotification('500 Tasks generiert', 'grn');
}
// =========================
// HASH-ROUTING
// =========================
/** Lauscht auf URL-Hash-Änderungen und schaltet die View */
function setupHashRouting() {
const views = ['board', 'streaks', 'insights'];
// Beim Seitenaufruf: View aus Hash lesen
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);
// Initiale Hash-Analyse
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);
});
});
// Theme Buttons
document.querySelectorAll('.theme-btns button').forEach(function (button) {
button.addEventListener('click', function () {
currentTheme = button.getAttribute('data-theme-value');
applyTheme();
saveState();
renderView();
});
});
// Neues Task Button (einmalig)
document.getElementById('btn-new').addEventListener('click', function () {
openTaskModal(null);
});
// Toolbar-Events (einmalig, keine Leaks)
const queryInput = document.getElementById('query');
const prioritySelect = document.getElementById('priority-filter');
let renderTimeout = null;
queryInput.addEventListener('input', function () {
clearTimeout(renderTimeout);
renderTimeout = setTimeout(renderBoard, 150);
});
prioritySelect.addEventListener('change', renderBoard);
// Suche: display:none statt DOM-Neuaufbau — bereits integriert (Query-Filter in renderBoard)
// =========================
// APP START
// =========================
loadState();
applyTheme();
subscribeToSystemThemeChange();
// Demo-Daten bei leerem LocalStorage
if (tasks.length === 0 && habits.length === 0) {
loadDemoData();
}
// View initial rendern
renderView();
})();
</script>
</body>
</html>