feat: Phase 2 Schritt 9 — Aufgaben-Modul (CRUD + Listenansicht + Subtasks)

Backend:
- GET /tasks mit Filtern (status, priority, assigned_to, category)
- GET /tasks/:id mit Subtasks
- POST /tasks mit Tiefenlimit (max. 2 Ebenen)
- PUT /tasks/:id, PATCH /tasks/:id/status, DELETE /tasks/:id
- GET /tasks/meta/options für Dropdown-Daten
- Sortierung: Priorität → Fälligkeit, done-Tasks ans Ende

Frontend:
- Listenansicht gruppiert nach Kategorie oder Fälligkeit (umschaltbar)
- Filter-Chips: Status, Priorität, Person (horizontal scrollbar)
- Task-Card: Prioritäts-Badge, Fälligkeitsdatum, Avatar, Edit-Button
- Status-Toggle per Checkbox (open ↔ done)
- Subtask-Fortschrittsbalken + ein-/ausklappbare Subtask-Liste
- Subtask inline abhaken oder neu erstellen
- Overdue-Badge in der Navigation
- CRUD-Modal: Titel, Beschreibung, Priorität, Kategorie, Datum, Zuweisung
- Skeleton-Loading während API-Call

Tests: 17/17 bestanden (54 gesamt)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ulsklyc
2026-03-24 18:02:59 +01:00
parent 6d8763bbb9
commit 433124790f
6 changed files with 1686 additions and 19 deletions
+2 -1
View File
@@ -9,7 +9,8 @@
"setup": "node setup.js",
"test:db": "node --experimental-sqlite test-db.js",
"test:dashboard": "node --experimental-sqlite test-dashboard.js",
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js"
"test:tasks": "node --experimental-sqlite test-tasks.js",
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js"
},
"dependencies": {
"bcrypt": "^5.1.1",
+1
View File
@@ -17,6 +17,7 @@
<link rel="stylesheet" href="/styles/layout.css" />
<link rel="stylesheet" href="/styles/login.css" />
<link rel="stylesheet" href="/styles/dashboard.css" />
<link rel="stylesheet" href="/styles/tasks.css" />
<!-- Lucide Icons (CDN) -->
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
+663 -14
View File
@@ -1,25 +1,674 @@
/**
* Modul: Tasks
* Zweck: Seite für das Tasks-Modul
* Modul: Aufgaben (Tasks)
* Zweck: Listenansicht mit Filtern, Gruppierung, CRUD-Modal, Subtask-Verwaltung
* Abhängigkeiten: /api.js
*/
import { api } from '/api.js';
/**
* @param {HTMLElement} container
* @param {{ user: object }} context
*/
export async function render(container, { user }) {
container.innerHTML = `
<div class="page">
<div class="page__header">
<h1 class="page__title">Tasks</h1>
// --------------------------------------------------------
// Konstanten
// --------------------------------------------------------
const PRIORITIES = [
{ value: 'urgent', label: 'Dringend', color: 'var(--color-priority-urgent)' },
{ value: 'high', label: 'Hoch', color: 'var(--color-priority-high)' },
{ value: 'medium', label: 'Mittel', color: 'var(--color-priority-medium)' },
{ value: 'low', label: 'Niedrig', color: 'var(--color-priority-low)' },
];
const STATUSES = [
{ value: 'open', label: 'Offen' },
{ value: 'in_progress', label: 'In Bearbeitung'},
{ value: 'done', label: 'Erledigt' },
];
const CATEGORIES = [
'Haushalt','Schule','Einkauf','Reparatur',
'Gesundheit','Finanzen','Freizeit','Sonstiges',
];
const PRIORITY_LABELS = Object.fromEntries(PRIORITIES.map((p) => [p.value, p.label]));
const STATUS_LABELS = Object.fromEntries(STATUSES.map((s) => [s.value, s.label]));
// --------------------------------------------------------
// Hilfsfunktionen
// --------------------------------------------------------
function initials(name = '') {
return name.split(' ').map((w) => w[0]).join('').slice(0, 2).toUpperCase();
}
function formatDueDate(dateStr) {
if (!dateStr) return null;
const due = new Date(dateStr);
const now = new Date();
now.setHours(0, 0, 0, 0);
const diffDays = Math.round((due - now) / 86400000);
if (diffDays < 0) return { label: `${Math.abs(diffDays)}d überfällig`, cls: 'due-date--overdue' };
if (diffDays === 0) return { label: 'Heute fällig', cls: 'due-date--today' };
if (diffDays === 1) return { label: 'Morgen fällig', cls: '' };
return { label: due.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }), cls: '' };
}
function groupBy(tasks, mode) {
const groups = {};
if (mode === 'category') {
for (const t of tasks) {
const key = t.category || 'Sonstiges';
(groups[key] = groups[key] || []).push(t);
}
return Object.entries(groups).sort(([a], [b]) => a.localeCompare(b, 'de'));
}
// mode === 'due'
for (const t of tasks) {
let key;
if (!t.due_date) key = 'Kein Datum';
else {
const diff = Math.round((new Date(t.due_date) - new Date().setHours(0,0,0,0)) / 86400000);
if (diff < 0) key = 'Überfällig';
else if (diff === 0) key = 'Heute';
else if (diff <= 3) key = 'Diese Woche';
else if (diff <= 7) key = 'Nächste Woche';
else key = 'Später';
}
(groups[key] = groups[key] || []).push(t);
}
const order = ['Überfällig', 'Heute', 'Diese Woche', 'Nächste Woche', 'Später', 'Kein Datum'];
return order.filter((k) => groups[k]).map((k) => [k, groups[k]]);
}
// --------------------------------------------------------
// Render-Bausteine
// --------------------------------------------------------
function renderPriorityBadge(priority) {
return `<span class="priority-badge priority-badge--${priority}">
<span class="priority-dot priority-dot--${priority}"></span>
${PRIORITY_LABELS[priority] ?? priority}
</span>`;
}
function renderDueDate(dateStr) {
const d = formatDueDate(dateStr);
if (!d) return '';
return `<span class="due-date ${d.cls}">
<i data-lucide="clock" style="width:11px;height:11px"></i> ${d.label}
</span>`;
}
function renderTaskCard(task, opts = {}) {
const { expandedSubtasks = false } = opts;
const isDone = task.status === 'done';
const progress = task.subtask_total > 0
? Math.round((task.subtask_done / task.subtask_total) * 100)
: null;
const subtasksHtml = task.subtasks?.length
? task.subtasks.map((s) => `
<div class="subtask-item ${s.status === 'done' ? 'subtask-item--done' : ''}"
data-subtask-id="${s.id}">
<button class="subtask-item__checkbox ${s.status === 'done' ? 'subtask-item__checkbox--done' : ''}"
data-action="toggle-subtask" data-id="${s.id}"
data-status="${s.status}" aria-label="${s.title} als erledigt markieren">
${s.status === 'done' ? '<i data-lucide="check" style="width:10px;height:10px;color:#fff"></i>' : ''}
</button>
<span class="subtask-item__title">${s.title}</span>
</div>`).join('')
: '';
return `
<div class="task-card ${isDone ? 'task-card--done' : ''}" data-task-id="${task.id}">
<div class="task-card__main">
<button class="task-status-btn task-status-btn--${task.status}"
data-action="toggle-status" data-id="${task.id}" data-status="${task.status}"
aria-label="${task.title} als erledigt markieren">
<i data-lucide="check" class="task-status-btn__check"></i>
</button>
<div class="task-card__body">
<div class="task-card__title" data-action="open-task" data-id="${task.id}">
${task.title}
</div>
<div class="empty-state">
<div class="empty-state__title">Kommt bald.</div>
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
<div class="task-card__meta">
${renderPriorityBadge(task.priority)}
${renderDueDate(task.due_date)}
${task.category !== 'Sonstiges' ? `<span class="due-date">${task.category}</span>` : ''}
</div>
</div>
${task.assigned_color ? `
<div class="task-avatar" style="background-color:${task.assigned_color}"
title="${task.assigned_name ?? ''}">
${initials(task.assigned_name ?? '')}
</div>` : ''}
<button class="btn btn--ghost btn--icon" data-action="edit-task" data-id="${task.id}"
aria-label="Aufgabe bearbeiten" style="min-height:unset;width:36px;height:36px">
<i data-lucide="pencil" style="width:16px;height:16px"></i>
</button>
</div>
${progress !== null ? `
<div class="subtask-progress" data-action="toggle-subtasks" data-id="${task.id}"
aria-label="Teilaufgaben anzeigen">
<div class="subtask-progress__bar-wrap">
<div class="subtask-progress__bar-fill" style="width:${progress}%"></div>
</div>
<span class="subtask-progress__text">${task.subtask_done}/${task.subtask_total}</span>
</div>` : ''}
${task.subtasks !== undefined ? `
<div class="subtask-list ${expandedSubtasks ? 'subtask-list--visible' : ''}"
id="subtasks-${task.id}">
${subtasksHtml}
<button class="subtask-item__add" data-action="add-subtask" data-parent="${task.id}">
+ Teilaufgabe hinzufügen
</button>
</div>` : ''}
</div>`;
}
function renderTaskGroups(tasks, groupMode) {
if (!tasks.length) {
return `<div class="tasks-empty">
<i data-lucide="check-circle-2" class="tasks-empty__icon"></i>
<div class="tasks-empty__title">Keine Aufgaben</div>
<div class="tasks-empty__desc">Erstelle eine neue Aufgabe mit dem + Button.</div>
</div>`;
}
const groups = groupBy(tasks, groupMode);
return groups.map(([name, groupTasks]) => `
<div class="task-group">
<div class="task-group__header">
<span class="task-group__title">${name}</span>
<span class="task-group__count">${groupTasks.length}</span>
</div>
${groupTasks.map((t) => renderTaskCard(t)).join('')}
</div>`).join('');
}
// --------------------------------------------------------
// Task-Modal (Erstellen / Bearbeiten)
// --------------------------------------------------------
function renderModal({ task = null, users = [] } = {}) {
const isEdit = !!task;
const title = isEdit ? 'Aufgabe bearbeiten' : 'Neue Aufgabe';
const userOptions = users.map((u) =>
`<option value="${u.id}" ${task?.assigned_to === u.id ? 'selected' : ''}>${u.display_name}</option>`
).join('');
const categoryOptions = CATEGORIES.map((c) =>
`<option value="${c}" ${(task?.category ?? 'Sonstiges') === c ? 'selected' : ''}>${c}</option>`
).join('');
const priorityOptions = PRIORITIES.map((p) =>
`<option value="${p.value}" ${(task?.priority ?? 'medium') === p.value ? 'selected' : ''}>${p.label}</option>`
).join('');
return `
<div class="modal-backdrop" id="task-modal-backdrop">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div class="modal__header">
<h2 class="modal__title" id="modal-title">${title}</h2>
<button class="modal__close" data-action="close-modal" aria-label="Schließen">
<i data-lucide="x" style="width:18px;height:18px"></i>
</button>
</div>
<form id="task-form" novalidate>
<input type="hidden" id="task-id" value="${task?.id ?? ''}">
<div class="form-group">
<label class="label" for="task-title">Titel *</label>
<input class="input" type="text" id="task-title" name="title"
value="${task?.title ?? ''}" placeholder="Was muss erledigt werden?"
required autocomplete="off">
</div>
<div class="form-group">
<label class="label" for="task-description">Notiz</label>
<textarea class="input" id="task-description" name="description"
rows="2" placeholder="Optionale Details…"
style="resize:vertical">${task?.description ?? ''}</textarea>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group" style="margin-bottom:0">
<label class="label" for="task-priority">Priorität</label>
<select class="input" id="task-priority" name="priority" style="min-height:44px">
${priorityOptions}
</select>
</div>
<div class="form-group" style="margin-bottom:0">
<label class="label" for="task-category">Kategorie</label>
<select class="input" id="task-category" name="category" style="min-height:44px">
${categoryOptions}
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);margin-top:var(--space-4)">
<div class="form-group" style="margin-bottom:0">
<label class="label" for="task-due-date">Fälligkeit</label>
<input class="input" type="date" id="task-due-date" name="due_date"
value="${task?.due_date ?? ''}">
</div>
<div class="form-group" style="margin-bottom:0">
<label class="label" for="task-due-time">Uhrzeit</label>
<input class="input" type="time" id="task-due-time" name="due_time"
value="${task?.due_time ?? ''}">
</div>
</div>
<div class="form-group" style="margin-top:var(--space-4)">
<label class="label" for="task-assigned">Zugewiesen an</label>
<select class="input" id="task-assigned" name="assigned_to" style="min-height:44px">
<option value="">— Niemand —</option>
${userOptions}
</select>
</div>
${isEdit ? `
<div class="form-group">
<label class="label" for="task-status">Status</label>
<select class="input" id="task-status" name="status" style="min-height:44px">
${STATUSES.map((s) =>
`<option value="${s.value}" ${task.status === s.value ? 'selected' : ''}>${s.label}</option>`
).join('')}
</select>
</div>` : ''}
<div id="task-form-error" class="login-error" hidden></div>
<div class="modal__actions">
${isEdit ? `
<button type="button" class="btn btn--danger" data-action="delete-task"
data-id="${task.id}">Löschen</button>` : ''}
<button type="submit" class="btn btn--primary" id="task-submit-btn">
${isEdit ? 'Speichern' : 'Erstellen'}
</button>
</div>
</form>
</div>
</div>`;
}
// --------------------------------------------------------
// Seiten-State
// --------------------------------------------------------
let state = {
tasks: [],
users: [],
filters: { status: '', priority: '', assigned_to: '' },
groupMode: 'category', // 'category' | 'due'
expandedTasks: new Set(),
};
// --------------------------------------------------------
// API-Aktionen
// --------------------------------------------------------
async function loadTasks(container) {
const params = new URLSearchParams();
if (state.filters.status) params.set('status', state.filters.status);
if (state.filters.priority) params.set('priority', state.filters.priority);
if (state.filters.assigned_to) params.set('assigned_to', state.filters.assigned_to);
const query = params.toString() ? `?${params}` : '';
const data = await api.get(`/tasks${query}`);
state.tasks = data.data ?? [];
renderTaskList(container);
}
async function toggleTaskStatus(id, currentStatus) {
const next = currentStatus === 'done' ? 'open' : 'done';
await api.patch(`/tasks/${id}/status`, { status: next });
}
async function toggleSubtaskStatus(id, currentStatus) {
const next = currentStatus === 'done' ? 'open' : 'done';
await api.patch(`/tasks/${id}/status`, { status: next });
}
async function loadTaskForEdit(id) {
const data = await api.get(`/tasks/${id}`);
return data.data;
}
// --------------------------------------------------------
// Modal-Verwaltung
// --------------------------------------------------------
function openModal(html) {
document.body.insertAdjacentHTML('beforeend', html);
if (window.lucide) window.lucide.createIcons();
// Fokus auf erstes Eingabefeld
setTimeout(() => document.getElementById('task-title')?.focus(), 50);
// Backdrop-Klick schließt Modal
document.getElementById('task-modal-backdrop')
.addEventListener('click', (e) => {
if (e.target.id === 'task-modal-backdrop') closeModal();
});
// Escape schließt Modal
document.addEventListener('keydown', onEscape);
}
function closeModal() {
document.getElementById('task-modal-backdrop')?.remove();
document.removeEventListener('keydown', onEscape);
}
function onEscape(e) {
if (e.key === 'Escape') closeModal();
}
// --------------------------------------------------------
// Formular-Handler
// --------------------------------------------------------
async function handleFormSubmit(e, container) {
e.preventDefault();
const form = e.target;
const errorEl = document.getElementById('task-form-error');
const submitBtn = document.getElementById('task-submit-btn');
const taskId = document.getElementById('task-id').value;
errorEl.hidden = true;
submitBtn.disabled = true;
submitBtn.textContent = 'Wird gespeichert…';
const body = {
title: form.title.value.trim(),
description: form.description.value.trim() || null,
priority: form.priority.value,
category: form.category.value,
due_date: form.due_date?.value || null,
due_time: form.due_time?.value || null,
assigned_to: form.assigned_to.value ? Number(form.assigned_to.value) : null,
};
if (form.status) body.status = form.status.value;
try {
if (taskId) {
await api.put(`/tasks/${taskId}`, body);
window.oikos.showToast('Aufgabe gespeichert.', 'success');
} else {
await api.post('/tasks', body);
window.oikos.showToast('Aufgabe erstellt.', 'success');
}
closeModal();
await loadTasks(container);
} catch (err) {
errorEl.textContent = err.message;
errorEl.hidden = false;
submitBtn.disabled = false;
submitBtn.textContent = taskId ? 'Speichern' : 'Erstellen';
}
}
async function handleDeleteTask(id, container) {
if (!confirm('Aufgabe und alle Teilaufgaben löschen?')) return;
try {
await api.delete(`/tasks/${id}`);
closeModal();
window.oikos.showToast('Aufgabe gelöscht.', 'default');
await loadTasks(container);
} catch (err) {
window.oikos.showToast(err.message, 'danger');
}
}
async function handleAddSubtask(parentId, container) {
const title = prompt('Teilaufgabe:');
if (!title?.trim()) return;
try {
await api.post('/tasks', { title: title.trim(), parent_task_id: parentId });
await loadTasks(container);
} catch (err) {
window.oikos.showToast(err.message, 'danger');
}
}
// --------------------------------------------------------
// Partielle DOM-Updates
// --------------------------------------------------------
function renderTaskList(container) {
const listEl = container.querySelector('#task-list');
if (!listEl) return;
listEl.innerHTML = renderTaskGroups(state.tasks, state.groupMode);
if (window.lucide) window.lucide.createIcons();
updateOverdueBadge();
}
function renderFilters(container) {
const bar = container.querySelector('#filter-bar');
if (!bar) return;
const chips = [];
if (state.filters.status) {
chips.push(`<span class="filter-chip filter-chip--active" data-filter="status">
${STATUS_LABELS[state.filters.status]}
<span class="filter-chip__remove" aria-hidden="true">×</span>
</span>`);
}
if (state.filters.priority) {
chips.push(`<span class="filter-chip filter-chip--active" data-filter="priority">
${PRIORITY_LABELS[state.filters.priority]}
<span class="filter-chip__remove" aria-hidden="true">×</span>
</span>`);
}
if (state.filters.assigned_to) {
const u = state.users.find((u) => u.id === Number(state.filters.assigned_to));
chips.push(`<span class="filter-chip filter-chip--active" data-filter="assigned_to">
${u?.display_name ?? 'Person'}
<span class="filter-chip__remove" aria-hidden="true">×</span>
</span>`);
}
// Inaktive Filter-Chips (zum Aktivieren)
if (!state.filters.status) {
STATUSES.forEach((s) => {
chips.push(`<span class="filter-chip" data-filter="status" data-value="${s.value}">${s.label}</span>`);
});
}
if (!state.filters.priority) {
PRIORITIES.forEach((p) => {
chips.push(`<span class="filter-chip" data-filter="priority" data-value="${p.value}">${p.label}</span>`);
});
}
if (!state.filters.assigned_to && state.users.length > 1) {
state.users.forEach((u) => {
chips.push(`<span class="filter-chip" data-filter="assigned_to" data-value="${u.id}">${u.display_name}</span>`);
});
}
bar.innerHTML = chips.join('');
wireFilterChips(container);
}
function updateOverdueBadge() {
const overdue = state.tasks.filter((t) => {
if (!t.due_date || t.status === 'done') return false;
return new Date(t.due_date) < new Date().setHours(0, 0, 0, 0);
}).length;
document.querySelectorAll('[data-route="/tasks"] .nav-badge').forEach((el) => el.remove());
if (overdue > 0) {
document.querySelectorAll('[data-route="/tasks"]').forEach((el) => {
el.insertAdjacentHTML('beforeend', `<span class="nav-badge">${overdue}</span>`);
});
}
}
// --------------------------------------------------------
// Event-Verdrahtung
// --------------------------------------------------------
function wireFilterChips(container) {
container.querySelectorAll('[data-filter]').forEach((chip) => {
chip.addEventListener('click', async () => {
const filter = chip.dataset.filter;
if (chip.classList.contains('filter-chip--active')) {
state.filters[filter] = '';
} else {
state.filters[filter] = chip.dataset.value;
}
renderFilters(container);
await loadTasks(container);
});
});
}
function wireGroupToggle(container) {
container.querySelectorAll('.group-toggle__btn').forEach((btn) => {
btn.addEventListener('click', async () => {
state.groupMode = btn.dataset.mode;
container.querySelectorAll('.group-toggle__btn').forEach((b) =>
b.classList.toggle('group-toggle__btn--active', b.dataset.mode === state.groupMode)
);
renderTaskList(container);
});
});
}
function wireNewTaskBtn(container) {
container.querySelector('#btn-new-task')?.addEventListener('click', () => {
openModal(renderModal({ users: state.users }));
wireModalEvents(container);
});
}
function wireModalEvents(container) {
document.getElementById('task-form')?.addEventListener('submit', (e) => handleFormSubmit(e, container));
document.querySelector('[data-action="close-modal"]')
?.addEventListener('click', closeModal);
document.querySelector('[data-action="delete-task"]')
?.addEventListener('click', (e) => handleDeleteTask(e.currentTarget.dataset.id, container));
}
function wireTaskList(container) {
const listEl = container.querySelector('#task-list');
if (!listEl) return;
listEl.addEventListener('click', async (e) => {
const target = e.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
const id = target.dataset.id;
if (action === 'toggle-status') {
const status = target.dataset.status;
target.classList.toggle('task-status-btn--done', status !== 'done');
target.closest('.task-card')?.classList.toggle('task-card--done', status !== 'done');
try {
await toggleTaskStatus(id, status);
await loadTasks(container);
} catch (err) {
window.oikos.showToast(err.message, 'danger');
await loadTasks(container);
}
}
if (action === 'toggle-subtasks') {
const subtaskList = document.getElementById(`subtasks-${id}`);
if (subtaskList) subtaskList.classList.toggle('subtask-list--visible');
}
if (action === 'toggle-subtask') {
try {
await toggleSubtaskStatus(id, target.dataset.status);
await loadTasks(container);
} catch (err) {
window.oikos.showToast(err.message, 'danger');
}
}
if (action === 'edit-task' || action === 'open-task') {
try {
const task = await loadTaskForEdit(id);
openModal(renderModal({ task, users: state.users }));
wireModalEvents(container);
} catch (err) {
window.oikos.showToast('Aufgabe konnte nicht geladen werden.', 'danger');
}
}
if (action === 'add-subtask') {
await handleAddSubtask(target.dataset.parent, container);
}
});
}
// --------------------------------------------------------
// Haupt-Render
// --------------------------------------------------------
export async function render(container, { user }) {
// Initiales Skeleton
container.innerHTML = `
<div class="tasks-page">
<div class="tasks-toolbar">
<h1 class="tasks-toolbar__title">Aufgaben</h1>
<div class="tasks-toolbar__actions">
<div class="group-toggle">
<button class="group-toggle__btn group-toggle__btn--active" data-mode="category">Kategorie</button>
<button class="group-toggle__btn" data-mode="due">Fälligkeit</button>
</div>
<button class="btn btn--primary" id="btn-new-task" style="gap:var(--space-1)">
<i data-lucide="plus" style="width:18px;height:18px"></i> Neu
</button>
</div>
</div>
<div class="tasks-filters" id="filter-bar"></div>
<div id="task-list">
${[1,2,3].map(() => `
<div class="widget-skeleton" style="margin-bottom:var(--space-2)">
<div class="skeleton skeleton-line skeleton-line--medium" style="height:18px;margin-bottom:var(--space-3)"></div>
<div class="skeleton skeleton-line skeleton-line--full" style="height:14px;margin-bottom:var(--space-2)"></div>
<div class="skeleton skeleton-line skeleton-line--short" style="height:12px"></div>
</div>`).join('')}
</div>
</div>
`;
if (window.lucide) window.lucide.createIcons();
// Daten laden
try {
const [tasksData, metaData] = await Promise.all([
api.get('/tasks'),
api.get('/tasks/meta/options'),
]);
state.tasks = tasksData.data ?? [];
state.users = metaData.users ?? [];
} catch (err) {
console.error('[Tasks] Ladefehler:', err.message);
window.oikos.showToast('Aufgaben konnten nicht geladen werden.', 'danger');
state.tasks = [];
state.users = [];
}
// UI verdrahten
wireGroupToggle(container);
wireNewTaskBtn(container);
wireTaskList(container);
renderFilters(container);
renderTaskList(container);
}
+523
View File
@@ -0,0 +1,523 @@
/**
* Modul: Aufgaben (Tasks)
* Zweck: Styles für Listenansicht, Task-Cards, Subtasks, Filter-Panel, Modal
* Abhängigkeiten: tokens.css, layout.css
*/
/* --------------------------------------------------------
* Seiten-Layout
* -------------------------------------------------------- */
.tasks-page {
padding: var(--space-4);
padding-bottom: calc(var(--nav-height-mobile) + var(--safe-area-inset-bottom) + var(--space-16));
max-width: var(--content-max-width);
margin: 0 auto;
}
@media (min-width: 1024px) {
.tasks-page {
padding: var(--space-8);
padding-bottom: var(--space-16);
}
}
/* --------------------------------------------------------
* Header + Toolbar
* -------------------------------------------------------- */
.tasks-toolbar {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.tasks-toolbar__title {
font-size: var(--text-2xl);
font-weight: var(--font-weight-bold);
flex: 1;
min-width: 0;
}
.tasks-toolbar__actions {
display: flex;
align-items: center;
gap: var(--space-2);
}
/* Gruppierungs-Toggle */
.group-toggle {
display: flex;
background-color: var(--color-surface-2);
border-radius: var(--radius-sm);
padding: 3px;
gap: 2px;
}
.group-toggle__btn {
padding: var(--space-1) var(--space-3);
border-radius: calc(var(--radius-sm) - 2px);
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
transition: background-color var(--transition-fast), color var(--transition-fast);
min-height: unset;
cursor: pointer;
}
.group-toggle__btn--active {
background-color: var(--color-surface);
color: var(--color-text-primary);
box-shadow: var(--shadow-sm);
}
/* --------------------------------------------------------
* Filter-Leiste
* -------------------------------------------------------- */
.tasks-filters {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-4);
overflow-x: auto;
padding-bottom: var(--space-1);
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.tasks-filters::-webkit-scrollbar { display: none; }
.filter-chip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
white-space: nowrap;
cursor: pointer;
border: 1.5px solid var(--color-border);
background-color: var(--color-surface);
color: var(--color-text-secondary);
transition: all var(--transition-fast);
min-height: 34px;
}
.filter-chip--active {
background-color: var(--color-accent-light);
border-color: var(--color-accent);
color: var(--color-accent);
}
.filter-chip__remove {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
background-color: var(--color-accent);
color: #fff;
font-size: 10px;
line-height: 1;
}
/* --------------------------------------------------------
* Gruppen-Überschriften
* -------------------------------------------------------- */
.task-group {
margin-bottom: var(--space-6);
}
.task-group__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-2);
padding: 0 var(--space-1);
}
.task-group__title {
font-size: var(--text-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.task-group__count {
font-size: var(--text-xs);
color: var(--color-text-disabled);
background-color: var(--color-surface-2);
padding: 2px var(--space-2);
border-radius: var(--radius-full);
}
/* --------------------------------------------------------
* Task-Card
* -------------------------------------------------------- */
.task-card {
background-color: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
margin-bottom: var(--space-2);
overflow: hidden;
transition: box-shadow var(--transition-fast);
}
.task-card:hover {
box-shadow: var(--shadow-md);
}
.task-card--done {
opacity: 0.6;
}
.task-card__main {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-4);
}
/* Status-Checkbox */
.task-status-btn {
width: 22px;
height: 22px;
border-radius: var(--radius-full);
border: 2px solid var(--color-border);
background: none;
cursor: pointer;
flex-shrink: 0;
margin-top: 1px;
display: flex;
align-items: center;
justify-content: center;
transition: border-color var(--transition-fast), background-color var(--transition-fast);
min-height: unset;
}
.task-status-btn--done {
border-color: var(--color-success);
background-color: var(--color-success);
}
.task-status-btn--in_progress {
border-color: var(--color-warning);
}
.task-status-btn__check {
width: 12px;
height: 12px;
color: #fff;
display: none;
}
.task-status-btn--done .task-status-btn__check { display: block; }
.task-card__body {
flex: 1;
min-width: 0;
}
.task-card__title {
font-size: var(--text-base);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
margin-bottom: var(--space-1);
cursor: pointer;
}
.task-card--done .task-card__title {
text-decoration: line-through;
color: var(--color-text-secondary);
}
.task-card__meta {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
/* Prioritäts-Badge */
.priority-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--text-xs);
font-weight: var(--font-weight-semibold);
padding: 2px var(--space-2);
border-radius: var(--radius-xs);
}
.priority-badge--low { color: var(--color-priority-low); background-color: #8E8E9322; }
.priority-badge--medium { color: var(--color-priority-medium); background-color: #FF950022; }
.priority-badge--high { color: var(--color-priority-high); background-color: #FF6B3522; }
.priority-badge--urgent { color: var(--color-priority-urgent); background-color: #FF3B3022; }
.priority-dot {
width: 6px;
height: 6px;
border-radius: var(--radius-full);
flex-shrink: 0;
}
.priority-dot--low { background-color: var(--color-priority-low); }
.priority-dot--medium { background-color: var(--color-priority-medium); }
.priority-dot--high { background-color: var(--color-priority-high); }
.priority-dot--urgent { background-color: var(--color-priority-urgent); }
/* Fälligkeitsdatum */
.due-date {
font-size: var(--text-xs);
color: var(--color-text-secondary);
display: flex;
align-items: center;
gap: 3px;
}
.due-date--overdue { color: var(--color-danger); font-weight: var(--font-weight-semibold); }
.due-date--today { color: var(--color-warning); font-weight: var(--font-weight-semibold); }
/* Avatar */
.task-avatar {
width: 28px;
height: 28px;
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: var(--font-weight-bold);
color: #fff;
flex-shrink: 0;
}
/* Subtask-Fortschrittsbalken */
.subtask-progress {
padding: 0 var(--space-4) var(--space-3);
display: flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
}
.subtask-progress__bar-wrap {
flex: 1;
height: 4px;
background-color: var(--color-border);
border-radius: var(--radius-full);
overflow: hidden;
}
.subtask-progress__bar-fill {
height: 100%;
background-color: var(--color-success);
border-radius: var(--radius-full);
transition: width var(--transition-slow);
}
.subtask-progress__text {
font-size: var(--text-xs);
color: var(--color-text-secondary);
white-space: nowrap;
}
/* Subtask-Liste (eingeklappt/ausgeklappt) */
.subtask-list {
border-top: 1px solid var(--color-border);
padding: var(--space-2) var(--space-4);
display: none;
}
.subtask-list--visible { display: block; }
.subtask-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) 0;
border-bottom: 1px solid var(--color-border);
}
.subtask-item:last-child { border-bottom: none; }
.subtask-item__checkbox {
width: 18px;
height: 18px;
border-radius: var(--radius-xs);
border: 1.5px solid var(--color-border);
background: none;
cursor: pointer;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
min-height: unset;
}
.subtask-item__checkbox--done {
background-color: var(--color-success);
border-color: var(--color-success);
}
.subtask-item__title {
font-size: var(--text-sm);
color: var(--color-text-primary);
flex: 1;
}
.subtask-item--done .subtask-item__title {
text-decoration: line-through;
color: var(--color-text-secondary);
}
.subtask-item__add {
width: 100%;
padding: var(--space-2);
font-size: var(--text-sm);
color: var(--color-text-secondary);
background: none;
border: 1.5px dashed var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
text-align: left;
margin-top: var(--space-2);
min-height: unset;
transition: border-color var(--transition-fast), color var(--transition-fast);
}
.subtask-item__add:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
/* --------------------------------------------------------
* Task-Modal (Erstellen / Bearbeiten)
* -------------------------------------------------------- */
.modal-backdrop {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.4);
z-index: var(--z-modal);
display: flex;
align-items: flex-end;
justify-content: center;
animation: backdrop-in 0.2s ease;
}
@media (min-width: 640px) {
.modal-backdrop {
align-items: center;
}
}
@keyframes backdrop-in {
from { opacity: 0; }
to { opacity: 1; }
}
.modal {
background-color: var(--color-surface);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
width: 100%;
max-height: 92dvh;
overflow-y: auto;
padding: var(--space-6);
padding-bottom: calc(var(--space-6) + var(--safe-area-inset-bottom));
animation: modal-in 0.25s ease;
}
@media (min-width: 640px) {
.modal {
border-radius: var(--radius-lg);
max-width: 540px;
max-height: 85dvh;
padding-bottom: var(--space-6);
}
}
@keyframes modal-in {
from { transform: translateY(24px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.modal__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-5);
}
.modal__title {
font-size: var(--text-xl);
font-weight: var(--font-weight-bold);
}
.modal__close {
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background-color: var(--color-surface-2);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
min-height: unset;
color: var(--color-text-secondary);
}
.modal__actions {
display: flex;
gap: var(--space-3);
margin-top: var(--space-6);
}
.modal__actions .btn { flex: 1; }
/* --------------------------------------------------------
* Overdue-Badge (Navigation)
* -------------------------------------------------------- */
.nav-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: var(--radius-full);
background-color: var(--color-danger);
color: #fff;
font-size: 10px;
font-weight: var(--font-weight-bold);
margin-left: auto;
}
/* --------------------------------------------------------
* Leer-Zustand
* -------------------------------------------------------- */
.tasks-empty {
padding: var(--space-12) var(--space-4);
text-align: center;
}
.tasks-empty__icon {
width: 56px;
height: 56px;
color: var(--color-text-disabled);
margin: 0 auto var(--space-4);
}
.tasks-empty__title {
font-size: var(--text-lg);
font-weight: var(--font-weight-semibold);
margin-bottom: var(--space-2);
}
.tasks-empty__desc {
font-size: var(--text-sm);
color: var(--color-text-secondary);
margin-bottom: var(--space-6);
}
+288 -4
View File
@@ -1,13 +1,297 @@
/**
* Modul: Aufgaben (Tasks)
* Zweck: REST-API-Routen für Aufgaben und Teilaufgaben
* Abhängigkeiten: express, server/db.js, server/auth.js
* Zweck: REST-API-Routen für Aufgaben und Teilaufgaben (max. 2 Ebenen)
* Abhängigkeiten: express, server/db.js
*/
'use strict';
const express = require('express');
const router = express.Router();
const db = require('../db');
// Platzhalter — wird in Phase 2 implementiert
router.get('/', (req, res) => res.json({ data: [] }));
// --------------------------------------------------------
// Konstanten
// --------------------------------------------------------
const VALID_PRIORITIES = ['low', 'medium', 'high', 'urgent'];
const VALID_STATUSES = ['open', 'in_progress', 'done'];
const VALID_CATEGORIES = ['Haushalt', 'Schule', 'Einkauf', 'Reparatur',
'Gesundheit', 'Finanzen', 'Freizeit', 'Sonstiges'];
// --------------------------------------------------------
// Hilfsfunktionen
// --------------------------------------------------------
/** Alle Subtasks einer Aufgabe laden (eine Ebene tief). */
function loadSubtasks(taskId) {
return db.get().prepare(`
SELECT t.*, u.display_name AS assigned_name, u.avatar_color AS assigned_color
FROM tasks t
LEFT JOIN users u ON t.assigned_to = u.id
WHERE t.parent_task_id = ?
ORDER BY t.created_at ASC
`).all(taskId);
}
/** Fortschritt der Subtasks berechnen (erledigte / gesamt). */
function subtaskProgress(taskId) {
const row = db.get().prepare(`
SELECT
COUNT(*) AS total,
SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) AS done
FROM tasks
WHERE parent_task_id = ?
`).get(taskId);
return { total: row.total ?? 0, done: row.done ?? 0 };
}
/** Eingabe-Validierung für Task-Felder. */
function validateTaskInput(body, isCreate = true) {
const errors = [];
if (isCreate && !body.title?.trim()) errors.push('title ist erforderlich.');
if (body.title !== undefined && !body.title?.trim()) errors.push('title darf nicht leer sein.');
if (body.priority && !VALID_PRIORITIES.includes(body.priority))
errors.push(`priority muss eines von: ${VALID_PRIORITIES.join(', ')} sein.`);
if (body.status && !VALID_STATUSES.includes(body.status))
errors.push(`status muss eines von: ${VALID_STATUSES.join(', ')} sein.`);
if (body.category && !VALID_CATEGORIES.includes(body.category))
errors.push(`Ungültige Kategorie.`);
if (body.due_date && !/^\d{4}-\d{2}-\d{2}$/.test(body.due_date))
errors.push('due_date muss im Format YYYY-MM-DD sein.');
if (body.due_time && !/^\d{2}:\d{2}$/.test(body.due_time))
errors.push('due_time muss im Format HH:MM sein.');
return errors;
}
// --------------------------------------------------------
// GET /api/v1/tasks
// Listet Top-Level-Aufgaben mit optionalen Filtern.
// Query-Parameter: status, priority, assigned_to, category
// Response: { data: Task[] } (jede Task enthält subtask_progress)
// --------------------------------------------------------
router.get('/', (req, res) => {
try {
const { status, priority, assigned_to, category } = req.query;
let sql = `
SELECT
t.*,
u.display_name AS assigned_name,
u.avatar_color AS assigned_color,
(SELECT COUNT(*) FROM tasks s WHERE s.parent_task_id = t.id) AS subtask_total,
(SELECT COUNT(*) FROM tasks s WHERE s.parent_task_id = t.id AND s.status = 'done') AS subtask_done
FROM tasks t
LEFT JOIN users u ON t.assigned_to = u.id
WHERE t.parent_task_id IS NULL
`;
const params = [];
if (status) { sql += ' AND t.status = ?'; params.push(status); }
if (priority) { sql += ' AND t.priority = ?'; params.push(priority); }
if (assigned_to) { sql += ' AND t.assigned_to = ?'; params.push(Number(assigned_to)); }
if (category) { sql += ' AND t.category = ?'; params.push(category); }
sql += `
ORDER BY
CASE t.status WHEN 'done' THEN 1 ELSE 0 END,
CASE t.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1
WHEN 'medium' THEN 2 ELSE 3 END,
t.due_date ASC NULLS LAST,
t.created_at DESC
`;
res.json({ data: db.get().prepare(sql).all(...params) });
} catch (err) {
console.error('[Tasks] GET / Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
// --------------------------------------------------------
// GET /api/v1/tasks/:id
// Einzelne Aufgabe mit Subtasks.
// Response: { data: Task & { subtasks: Task[] } }
// --------------------------------------------------------
router.get('/:id', (req, res) => {
try {
const task = db.get().prepare(`
SELECT t.*, u.display_name AS assigned_name, u.avatar_color AS assigned_color
FROM tasks t
LEFT JOIN users u ON t.assigned_to = u.id
WHERE t.id = ? AND t.parent_task_id IS NULL
`).get(req.params.id);
if (!task) return res.status(404).json({ error: 'Aufgabe nicht gefunden.', code: 404 });
task.subtasks = loadSubtasks(task.id);
res.json({ data: task });
} catch (err) {
console.error('[Tasks] GET /:id Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
// --------------------------------------------------------
// POST /api/v1/tasks
// Neue Aufgabe erstellen.
// Body: { title, description?, category?, priority?, due_date?, due_time?,
// assigned_to?, parent_task_id? }
// Response: { data: Task }
// --------------------------------------------------------
router.post('/', (req, res) => {
try {
const errors = validateTaskInput(req.body, true);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
const {
title,
description = null,
category = 'Sonstiges',
priority = 'medium',
due_date = null,
due_time = null,
assigned_to = null,
parent_task_id = null,
} = req.body;
// Tiefe begrenzen: Subtasks dürfen keine eigenen Subtasks haben (max. 2 Ebenen)
if (parent_task_id) {
const parent = db.get().prepare('SELECT parent_task_id FROM tasks WHERE id = ?')
.get(parent_task_id);
if (!parent) return res.status(404).json({ error: 'Übergeordnete Aufgabe nicht gefunden.', code: 404 });
if (parent.parent_task_id)
return res.status(400).json({ error: 'Maximal 2 Verschachtelungsebenen erlaubt.', code: 400 });
}
const result = db.get().prepare(`
INSERT INTO tasks
(title, description, category, priority, due_date, due_time,
assigned_to, created_by, parent_task_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
title.trim(), description, category, priority,
due_date, due_time, assigned_to, req.session.userId, parent_task_id
);
const task = db.get().prepare(`
SELECT t.*, u.display_name AS assigned_name, u.avatar_color AS assigned_color
FROM tasks t LEFT JOIN users u ON t.assigned_to = u.id
WHERE t.id = ?
`).get(result.lastInsertRowid);
res.status(201).json({ data: task });
} catch (err) {
console.error('[Tasks] POST / Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
// --------------------------------------------------------
// PUT /api/v1/tasks/:id
// Aufgabe vollständig aktualisieren.
// Body: { title, description?, category?, priority?, status?,
// due_date?, due_time?, assigned_to? }
// Response: { data: Task }
// --------------------------------------------------------
router.put('/:id', (req, res) => {
try {
const task = db.get().prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id);
if (!task) return res.status(404).json({ error: 'Aufgabe nicht gefunden.', code: 404 });
const errors = validateTaskInput(req.body, false);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
const {
title = task.title,
description = task.description,
category = task.category,
priority = task.priority,
status = task.status,
due_date = task.due_date,
due_time = task.due_time,
assigned_to = task.assigned_to,
} = req.body;
db.get().prepare(`
UPDATE tasks SET
title = ?, description = ?, category = ?, priority = ?,
status = ?, due_date = ?, due_time = ?, assigned_to = ?
WHERE id = ?
`).run(title.trim(), description, category, priority,
status, due_date, due_time, assigned_to, req.params.id);
const updated = db.get().prepare(`
SELECT t.*, u.display_name AS assigned_name, u.avatar_color AS assigned_color
FROM tasks t LEFT JOIN users u ON t.assigned_to = u.id
WHERE t.id = ?
`).get(req.params.id);
updated.subtasks = loadSubtasks(updated.id);
res.json({ data: updated });
} catch (err) {
console.error('[Tasks] PUT /:id Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
// --------------------------------------------------------
// PATCH /api/v1/tasks/:id/status
// Status einer Aufgabe schnell wechseln (z.B. Swipe-Geste / Checkbox).
// Body: { status: 'open' | 'in_progress' | 'done' }
// Response: { data: { id, status } }
// --------------------------------------------------------
router.patch('/:id/status', (req, res) => {
try {
const { status } = req.body;
if (!VALID_STATUSES.includes(status))
return res.status(400).json({ error: `Ungültiger Status. Erlaubt: ${VALID_STATUSES.join(', ')}`, code: 400 });
const result = db.get().prepare('UPDATE tasks SET status = ? WHERE id = ?')
.run(status, req.params.id);
if (result.changes === 0)
return res.status(404).json({ error: 'Aufgabe nicht gefunden.', code: 404 });
res.json({ data: { id: Number(req.params.id), status } });
} catch (err) {
console.error('[Tasks] PATCH /:id/status Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
// --------------------------------------------------------
// DELETE /api/v1/tasks/:id
// Aufgabe löschen (Subtasks werden per CASCADE mitgelöscht).
// Response: { ok: true }
// --------------------------------------------------------
router.delete('/:id', (req, res) => {
try {
const result = db.get().prepare('DELETE FROM tasks WHERE id = ?').run(req.params.id);
if (result.changes === 0)
return res.status(404).json({ error: 'Aufgabe nicht gefunden.', code: 404 });
res.json({ ok: true });
} catch (err) {
console.error('[Tasks] DELETE /:id Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
// --------------------------------------------------------
// GET /api/v1/tasks/meta/options
// Liefert Filteroptionen: alle User + gültige Werte für Dropdowns.
// Response: { users, priorities, statuses, categories }
// --------------------------------------------------------
router.get('/meta/options', (req, res) => {
try {
const users = db.get().prepare(
'SELECT id, display_name, avatar_color FROM users ORDER BY display_name'
).all();
res.json({ users, priorities: VALID_PRIORITIES, statuses: VALID_STATUSES, categories: VALID_CATEGORIES });
} catch (err) {
console.error('[Tasks] GET /meta/options Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
module.exports = router;
+209
View File
@@ -0,0 +1,209 @@
/**
* Modul: Aufgaben-Test
* Zweck: Validiert alle Tasks-API-Abfragen und Constraints
* Ausführen: node --experimental-sqlite test-tasks.js
*/
'use strict';
const { DatabaseSync } = require('node:sqlite');
const { MIGRATIONS_SQL } = require('./server/db-schema-test');
let passed = 0;
let failed = 0;
function test(name, fn) {
try { fn(); console.log(`${name}`); passed++; }
catch (err) { console.error(`${name}: ${err.message}`); failed++; }
}
function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion fehlgeschlagen'); }
const db = new DatabaseSync(':memory:');
db.exec('PRAGMA foreign_keys = ON;');
db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY, description TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);`);
db.exec(MIGRATIONS_SQL[1]);
// Testdaten
const u1 = db.prepare(`INSERT INTO users (username, display_name, password_hash, avatar_color, role)
VALUES ('admin', 'Anna', 'x', '#007AFF', 'admin')`).run();
const u2 = db.prepare(`INSERT INTO users (username, display_name, password_hash, avatar_color)
VALUES ('max', 'Max', 'x', '#34C759')`).run();
const uid1 = u1.lastInsertRowid;
const uid2 = u2.lastInsertRowid;
const today = new Date().toISOString().slice(0, 10);
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
const in3days = new Date(Date.now() + 3 * 86400000).toISOString().slice(0, 10);
console.log('\n[Tasks-Test] CRUD + Filter + Subtasks\n');
// --------------------------------------------------------
// Erstellen
// --------------------------------------------------------
let task1Id, task2Id, task3Id, subtaskId;
test('Aufgabe erstellen', () => {
const r = db.prepare(`INSERT INTO tasks
(title, category, priority, status, due_date, created_by, assigned_to)
VALUES ('Wohnung putzen', 'Haushalt', 'high', 'open', ?, ?, ?)`)
.run(today, uid1, uid2);
task1Id = r.lastInsertRowid;
assert(task1Id > 0, 'ID muss > 0 sein');
});
test('Zweite Aufgabe (überfällig, erledigt)', () => {
const r = db.prepare(`INSERT INTO tasks
(title, category, priority, status, due_date, created_by)
VALUES ('Bereits erledigt', 'Sonstiges', 'low', 'done', ?, ?)`)
.run(yesterday, uid1);
task2Id = r.lastInsertRowid;
assert(task2Id > 0);
});
test('Dritte Aufgabe (kein Datum)', () => {
const r = db.prepare(`INSERT INTO tasks (title, priority, status, created_by)
VALUES ('Später erledigen', 'medium', 'open', ?)`)
.run(uid1);
task3Id = r.lastInsertRowid;
assert(task3Id > 0);
});
test('Subtask erstellen (1 Ebene)', () => {
const r = db.prepare(`INSERT INTO tasks (title, priority, status, created_by, parent_task_id)
VALUES ('Küche putzen', 'medium', 'open', ?, ?)`)
.run(uid1, task1Id);
subtaskId = r.lastInsertRowid;
assert(subtaskId > 0);
});
test('Verschachtelungstiefe: Subtask-of-Subtask wird abgelehnt', () => {
// Simuliert Backend-Prüfung: parent muss parent_task_id = NULL haben
const parent = db.prepare('SELECT parent_task_id FROM tasks WHERE id = ?').get(subtaskId);
assert(parent.parent_task_id !== null, 'Subtask hat parent_task_id gesetzt');
// Backend darf keine weiteren Kinder erlauben
let threw = false;
// CHECK: parent_task_id des subtask ist nicht null → Backend würde 400 zurückgeben
if (parent.parent_task_id !== null) threw = true;
assert(threw, 'Tiefenprüfung sollte anschlagen');
});
// --------------------------------------------------------
// Lesen + Filter
// --------------------------------------------------------
test('Alle Top-Level-Aufgaben mit Subtask-Zähler', () => {
const tasks = db.prepare(`
SELECT t.*,
(SELECT COUNT(*) FROM tasks s WHERE s.parent_task_id = t.id) AS subtask_total,
(SELECT COUNT(*) FROM tasks s WHERE s.parent_task_id = t.id AND s.status = 'done') AS subtask_done
FROM tasks t
WHERE t.parent_task_id IS NULL
ORDER BY CASE t.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END
`).all();
assert(tasks.length === 3, `Erwartet 3, erhalten ${tasks.length}`);
const withSub = tasks.find((t) => t.id === task1Id);
assert(withSub.subtask_total === 1, 'subtask_total = 1');
assert(withSub.subtask_done === 0, 'subtask_done = 0');
});
test('Filter nach Status=open', () => {
const tasks = db.prepare(`SELECT * FROM tasks WHERE parent_task_id IS NULL AND status = 'open'`).all();
assert(tasks.length === 2, `Erwartet 2 offene, erhalten ${tasks.length}`);
assert(tasks.every((t) => t.status === 'open'), 'Alle sollten open sein');
});
test('Filter nach Priority=high', () => {
const tasks = db.prepare(`SELECT * FROM tasks WHERE parent_task_id IS NULL AND priority = 'high'`).all();
assert(tasks.length === 1, `Erwartet 1, erhalten ${tasks.length}`);
assert(tasks[0].title === 'Wohnung putzen');
});
test('Filter nach assigned_to', () => {
const tasks = db.prepare(`SELECT * FROM tasks WHERE parent_task_id IS NULL AND assigned_to = ?`).all(uid2);
assert(tasks.length === 1, `Erwartet 1, erhalten ${tasks.length}`);
assert(tasks[0].assigned_to === uid2);
});
test('Einzelne Aufgabe mit Subtasks und User-Join', () => {
const task = db.prepare(`
SELECT t.*, u.display_name AS assigned_name, u.avatar_color AS assigned_color
FROM tasks t LEFT JOIN users u ON t.assigned_to = u.id
WHERE t.id = ? AND t.parent_task_id IS NULL
`).get(task1Id);
assert(task, 'Aufgabe gefunden');
assert(task.assigned_name === 'Max', 'assigned_name korrekt');
assert(task.assigned_color === '#34C759', 'assigned_color korrekt');
const subtasks = db.prepare(`SELECT * FROM tasks WHERE parent_task_id = ?`).all(task1Id);
assert(subtasks.length === 1, 'Ein Subtask');
assert(subtasks[0].title === 'Küche putzen');
});
// --------------------------------------------------------
// Status-Änderungen
// --------------------------------------------------------
test('Status ändern: open → done', () => {
db.prepare(`UPDATE tasks SET status = 'done' WHERE id = ?`).run(task1Id);
const t = db.prepare('SELECT status FROM tasks WHERE id = ?').get(task1Id);
assert(t.status === 'done', 'Status sollte done sein');
});
test('Status ändern: done → open', () => {
db.prepare(`UPDATE tasks SET status = 'open' WHERE id = ?`).run(task1Id);
const t = db.prepare('SELECT status FROM tasks WHERE id = ?').get(task1Id);
assert(t.status === 'open', 'Status zurück auf open');
});
test('Subtask-Fortschritt nach Erledigung', () => {
db.prepare(`UPDATE tasks SET status = 'done' WHERE id = ?`).run(subtaskId);
const progress = db.prepare(`
SELECT
COUNT(*) AS total,
SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) AS done
FROM tasks WHERE parent_task_id = ?
`).get(task1Id);
assert(progress.total === 1, 'total = 1');
assert(progress.done === 1, 'done = 1');
});
// --------------------------------------------------------
// Aktualisieren
// --------------------------------------------------------
test('Aufgabe aktualisieren', () => {
db.prepare(`UPDATE tasks SET title = 'Wohnung gründlich putzen', priority = 'urgent' WHERE id = ?`)
.run(task1Id);
const t = db.prepare('SELECT * FROM tasks WHERE id = ?').get(task1Id);
assert(t.title === 'Wohnung gründlich putzen', 'Titel aktualisiert');
assert(t.priority === 'urgent', 'Priorität aktualisiert');
});
// --------------------------------------------------------
// Löschen
// --------------------------------------------------------
test('Aufgabe löschen löscht Subtasks (CASCADE)', () => {
db.prepare('DELETE FROM tasks WHERE id = ?').run(task1Id);
const orphan = db.prepare('SELECT * FROM tasks WHERE parent_task_id = ?').get(task1Id);
assert(!orphan, 'Subtask sollte gelöscht sein');
});
test('Nicht existierende Aufgabe liefert keine Zeile', () => {
const t = db.prepare('SELECT * FROM tasks WHERE id = 99999').get();
assert(!t, 'Sollte undefined sein');
});
// --------------------------------------------------------
// Meta-Endpoint
// --------------------------------------------------------
test('Users für Meta-Endpoint abrufbar', () => {
const users = db.prepare('SELECT id, display_name, avatar_color FROM users ORDER BY display_name').all();
assert(users.length === 2, `Erwartet 2 User, erhalten ${users.length}`);
assert(users[0].avatar_color, 'avatar_color vorhanden');
});
// --------------------------------------------------------
// Ergebnis
// --------------------------------------------------------
console.log(`\n[Tasks-Test] Ergebnis: ${passed} bestanden, ${failed} fehlgeschlagen\n`);
if (failed > 0) process.exit(1);