feat: Phase 2 Schritt 8 — Dashboard mit allen Widgets
- Aggregierter GET /api/v1/dashboard Endpoint (1 Request für alle Widgets) - Widget: Begrüßung mit tageszeit-abhängigem Text + aktuellem Datum - Widget: Dringende Aufgaben (priority high/urgent, fällig ≤ 48h, nicht done) - Widget: Anstehende Termine (nächste 5, mit Avatar-Farbe) - Widget: Heutiges Essen (nach Mahlzeit-Typ sortiert) - Widget: Angepinnte Notizen (max. 3, mit Notizfarbe) - Skeleton-Loading-States während API-Call (keine Spinner) - FAB Speed-Dial: + Aufgabe, + Termin, + Einkauf, + Notiz - Responsives 1/2/3-Spalten-Grid (Mobil / Tablet / Desktop) - Dashboard-Tests: 8/8 bestanden (node:sqlite) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+3
-1
@@ -7,7 +7,9 @@
|
|||||||
"start": "node server/index.js",
|
"start": "node server/index.js",
|
||||||
"dev": "node --watch server/index.js",
|
"dev": "node --watch server/index.js",
|
||||||
"setup": "node setup.js",
|
"setup": "node setup.js",
|
||||||
"test:db": "node --experimental-sqlite test-db.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"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<link rel="stylesheet" href="/styles/reset.css" />
|
<link rel="stylesheet" href="/styles/reset.css" />
|
||||||
<link rel="stylesheet" href="/styles/layout.css" />
|
<link rel="stylesheet" href="/styles/layout.css" />
|
||||||
<link rel="stylesheet" href="/styles/login.css" />
|
<link rel="stylesheet" href="/styles/login.css" />
|
||||||
|
<link rel="stylesheet" href="/styles/dashboard.css" />
|
||||||
|
|
||||||
<!-- Lucide Icons (CDN) -->
|
<!-- Lucide Icons (CDN) -->
|
||||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||||
|
|||||||
+353
-13
@@ -1,25 +1,365 @@
|
|||||||
/**
|
/**
|
||||||
* Modul: Dashboard
|
* Modul: Dashboard
|
||||||
* Zweck: Seite für das Dashboard-Modul
|
* Zweck: Startseite mit Begrüßung, Terminen, Aufgaben, Essen, Notizen und FAB
|
||||||
* Abhängigkeiten: /api.js
|
* Abhängigkeiten: /api.js
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { api } from '/api.js';
|
import { api } from '/api.js';
|
||||||
|
|
||||||
/**
|
// --------------------------------------------------------
|
||||||
* @param {HTMLElement} container
|
// Hilfsfunktionen
|
||||||
* @param {{ user: object }} context
|
// --------------------------------------------------------
|
||||||
*/
|
|
||||||
export async function render(container, { user }) {
|
function greeting(displayName) {
|
||||||
container.innerHTML = `
|
const h = new Date().getHours();
|
||||||
<div class="page">
|
const tageszeit = h < 12 ? 'Morgen' : h < 18 ? 'Tag' : 'Abend';
|
||||||
<div class="page__header">
|
return `Guten ${tageszeit}, ${displayName}`;
|
||||||
<h1 class="page__title">Dashboard</h1>
|
}
|
||||||
|
|
||||||
|
function formatDate(date = new Date()) {
|
||||||
|
return date.toLocaleDateString('de-DE', {
|
||||||
|
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(isoString) {
|
||||||
|
if (!isoString) return '';
|
||||||
|
const d = new Date(isoString);
|
||||||
|
const today = new Date();
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(today.getDate() + 1);
|
||||||
|
|
||||||
|
const dateStr = d.toDateString() === today.toDateString()
|
||||||
|
? 'Heute'
|
||||||
|
: d.toDateString() === tomorrow.toDateString()
|
||||||
|
? 'Morgen'
|
||||||
|
: d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
|
||||||
|
|
||||||
|
const timeStr = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
return `${dateStr}, ${timeStr} Uhr`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDueDate(dateStr) {
|
||||||
|
if (!dateStr) return null;
|
||||||
|
const due = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = due - now;
|
||||||
|
const diffH = diffMs / (1000 * 60 * 60);
|
||||||
|
|
||||||
|
if (diffMs < 0) return { text: 'Überfällig', overdue: true };
|
||||||
|
if (diffH < 24) return { text: 'Heute fällig', overdue: false };
|
||||||
|
if (diffH < 48) return { text: 'Morgen fällig', overdue: false };
|
||||||
|
return {
|
||||||
|
text: due.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }),
|
||||||
|
overdue: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const MEAL_LABELS = {
|
||||||
|
breakfast: 'Frühstück',
|
||||||
|
lunch: 'Mittagessen',
|
||||||
|
dinner: 'Abendessen',
|
||||||
|
snack: 'Snack',
|
||||||
|
};
|
||||||
|
|
||||||
|
function initials(name = '') {
|
||||||
|
return name.split(' ').map((w) => w[0]).join('').slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Skeleton
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
function skeletonWidget(lines = 3) {
|
||||||
|
const lineHtml = Array.from({ length: lines }, (_, i) => `
|
||||||
|
<div class="skeleton skeleton-line ${i % 2 === 0 ? 'skeleton-line--full' : 'skeleton-line--medium'}"
|
||||||
|
style="margin-bottom:var(--space-2)"></div>
|
||||||
|
`).join('');
|
||||||
|
return `
|
||||||
|
<div class="widget-skeleton">
|
||||||
|
<div class="skeleton skeleton-line skeleton-line--short"
|
||||||
|
style="height:16px;margin-bottom:var(--space-4)"></div>
|
||||||
|
${lineHtml}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Widget-Renderer
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
function renderGreeting(user) {
|
||||||
|
return `
|
||||||
|
<div class="widget-greeting">
|
||||||
|
<div class="widget-greeting__title">${greeting(user.display_name)}</div>
|
||||||
|
<div class="widget-greeting__date">${formatDate()}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUrgentTasks(tasks) {
|
||||||
|
const header = `
|
||||||
|
<div class="widget__header">
|
||||||
|
<span class="widget__title">
|
||||||
|
<i data-lucide="alert-circle" class="widget__title-icon" aria-hidden="true"></i>
|
||||||
|
Dringende Aufgaben
|
||||||
|
</span>
|
||||||
|
<a href="/tasks" data-route="/tasks" class="widget__link">Alle</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!tasks.length) {
|
||||||
|
return `<div class="widget">${header}
|
||||||
|
<div class="widget__empty">Keine dringenden Aufgaben. ✓</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = tasks.map((t) => {
|
||||||
|
const due = formatDueDate(t.due_date);
|
||||||
|
return `
|
||||||
|
<div class="task-item" data-route="/tasks" role="button" tabindex="0"
|
||||||
|
aria-label="Aufgabe: ${t.title}">
|
||||||
|
<div class="task-item__priority task-item__priority--${t.priority}"></div>
|
||||||
|
<div class="task-item__content">
|
||||||
|
<div class="task-item__title">${t.title}</div>
|
||||||
|
${due ? `<div class="task-item__meta ${due.overdue ? 'task-item__meta--overdue' : ''}">${due.text}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
${t.assigned_color ? `
|
||||||
|
<div class="task-item__avatar" style="background-color:${t.assigned_color}"
|
||||||
|
title="${t.assigned_name || ''}">
|
||||||
|
${initials(t.assigned_name || '')}
|
||||||
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="empty-state">
|
`;
|
||||||
<div class="empty-state__title">Kommt bald.</div>
|
}).join('');
|
||||||
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
|
|
||||||
|
return `<div class="widget">${header}<div class="widget__body">${items}</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUpcomingEvents(events) {
|
||||||
|
const header = `
|
||||||
|
<div class="widget__header">
|
||||||
|
<span class="widget__title">
|
||||||
|
<i data-lucide="calendar" class="widget__title-icon" aria-hidden="true"></i>
|
||||||
|
Anstehende Termine
|
||||||
|
</span>
|
||||||
|
<a href="/calendar" data-route="/calendar" class="widget__link">Alle</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!events.length) {
|
||||||
|
return `<div class="widget">${header}
|
||||||
|
<div class="widget__empty">Keine anstehenden Termine.</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = events.map((e) => `
|
||||||
|
<div class="event-item" data-route="/calendar" role="button" tabindex="0"
|
||||||
|
aria-label="Termin: ${e.title}">
|
||||||
|
<div class="event-item__bar"
|
||||||
|
style="background-color:${e.assigned_color || e.color || 'var(--color-accent)'}"></div>
|
||||||
|
<div class="event-item__content">
|
||||||
|
<div class="event-item__title">${e.title}</div>
|
||||||
|
<div class="event-item__time">
|
||||||
|
${e.all_day ? formatDate(new Date(e.start_datetime)) : formatDateTime(e.start_datetime)}
|
||||||
|
${e.location ? ` · ${e.location}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
return `<div class="widget">${header}<div class="widget__body">${items}</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTodayMeals(meals) {
|
||||||
|
const header = `
|
||||||
|
<div class="widget__header">
|
||||||
|
<span class="widget__title">
|
||||||
|
<i data-lucide="utensils" class="widget__title-icon" aria-hidden="true"></i>
|
||||||
|
Heute essen
|
||||||
|
</span>
|
||||||
|
<a href="/meals" data-route="/meals" class="widget__link">Alle</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!meals.length) {
|
||||||
|
return `<div class="widget">${header}
|
||||||
|
<div class="widget__empty">Kein Essensplan für heute.</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = meals.map((m) => `
|
||||||
|
<div class="meal-item" data-route="/meals" role="button" tabindex="0"
|
||||||
|
aria-label="${MEAL_LABELS[m.meal_type]}: ${m.title}">
|
||||||
|
<span class="meal-item__type-badge">${MEAL_LABELS[m.meal_type]}</span>
|
||||||
|
<span class="meal-item__title">${m.title}</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
return `<div class="widget">${header}<div class="widget__body">${items}</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPinnedNotes(notes) {
|
||||||
|
const header = `
|
||||||
|
<div class="widget__header">
|
||||||
|
<span class="widget__title">
|
||||||
|
<i data-lucide="pin" class="widget__title-icon" aria-hidden="true"></i>
|
||||||
|
Pinnwand
|
||||||
|
</span>
|
||||||
|
<a href="/notes" data-route="/notes" class="widget__link">Alle</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!notes.length) {
|
||||||
|
return `<div class="widget">${header}
|
||||||
|
<div class="widget__empty">Keine angepinnten Notizen.</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = notes.map((n) => `
|
||||||
|
<div class="note-item" data-route="/notes" role="button" tabindex="0"
|
||||||
|
style="background-color:${n.color}22; border-left-color:${n.color};"
|
||||||
|
aria-label="Notiz${n.title ? ': ' + n.title : ''}">
|
||||||
|
${n.title ? `<div class="note-item__title">${n.title}</div>` : ''}
|
||||||
|
<div class="note-item__content">${n.content}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
return `<div class="widget">${header}<div class="widget__body">${items}</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// FAB Speed-Dial
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
const FAB_ACTIONS = [
|
||||||
|
{ route: '/tasks', label: 'Aufgabe', icon: 'check-square' },
|
||||||
|
{ route: '/calendar', label: 'Termin', icon: 'calendar-plus' },
|
||||||
|
{ route: '/shopping', label: 'Einkauf', icon: 'shopping-cart' },
|
||||||
|
{ route: '/notes', label: 'Notiz', icon: 'sticky-note' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function renderFab() {
|
||||||
|
const actionsHtml = FAB_ACTIONS.map((a) => `
|
||||||
|
<div class="fab-action" data-route="${a.route}" role="button" tabindex="-1"
|
||||||
|
aria-label="${a.label} hinzufügen">
|
||||||
|
<span class="fab-action__label">${a.label}</span>
|
||||||
|
<button class="fab-action__btn" tabindex="-1" aria-hidden="true">
|
||||||
|
<i data-lucide="${a.icon}" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="fab-container" id="fab-container">
|
||||||
|
<button class="fab-main" id="fab-main" aria-label="Schnellaktionen" aria-expanded="false">
|
||||||
|
<i data-lucide="plus" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<div class="fab-actions" id="fab-actions" aria-hidden="true">
|
||||||
|
${actionsHtml}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initFab(container) {
|
||||||
|
const fabMain = container.querySelector('#fab-main');
|
||||||
|
const fabActions = container.querySelector('#fab-actions');
|
||||||
|
if (!fabMain) return;
|
||||||
|
|
||||||
|
let open = false;
|
||||||
|
|
||||||
|
function toggleFab(force) {
|
||||||
|
open = force !== undefined ? force : !open;
|
||||||
|
fabMain.classList.toggle('fab-main--open', open);
|
||||||
|
fabMain.setAttribute('aria-expanded', String(open));
|
||||||
|
fabActions.classList.toggle('fab-actions--visible', open);
|
||||||
|
fabActions.setAttribute('aria-hidden', String(!open));
|
||||||
|
fabActions.querySelectorAll('[role="button"]').forEach((el) => {
|
||||||
|
el.tabIndex = open ? 0 : -1;
|
||||||
|
});
|
||||||
|
if (window.lucide) window.lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
fabMain.addEventListener('click', (e) => { e.stopPropagation(); toggleFab(); });
|
||||||
|
|
||||||
|
fabActions.querySelectorAll('[data-route]').forEach((el) => {
|
||||||
|
const go = () => { toggleFab(false); window.oikos.navigate(el.dataset.route); };
|
||||||
|
el.addEventListener('click', go);
|
||||||
|
el.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', () => { if (open) toggleFab(false); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Navigations-Links verdrahten
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
function wireLinks(container) {
|
||||||
|
container.querySelectorAll('[data-route]').forEach((el) => {
|
||||||
|
if (el.id === 'fab-main' || el.closest('#fab-actions')) return; // FAB separat
|
||||||
|
const go = () => window.oikos.navigate(el.dataset.route);
|
||||||
|
if (el.tagName === 'A') {
|
||||||
|
el.addEventListener('click', (e) => { e.preventDefault(); go(); });
|
||||||
|
} else {
|
||||||
|
el.addEventListener('click', go);
|
||||||
|
el.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Haupt-Render
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
export async function render(container, { user }) {
|
||||||
|
// Sofort Skeleton
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="dashboard">
|
||||||
|
<div class="dashboard__grid">
|
||||||
|
<div class="widget-greeting" style="grid-column:1/-1">
|
||||||
|
<div class="widget-greeting__title">${greeting(user.display_name)}</div>
|
||||||
|
<div class="widget-greeting__date">${formatDate()}</div>
|
||||||
|
</div>
|
||||||
|
${skeletonWidget(3)}
|
||||||
|
${skeletonWidget(3)}
|
||||||
|
${skeletonWidget(2)}
|
||||||
|
${skeletonWidget(3)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${renderFab()}
|
||||||
|
`;
|
||||||
|
initFab(container);
|
||||||
|
|
||||||
|
// Daten laden
|
||||||
|
let data = { upcomingEvents: [], urgentTasks: [], todayMeals: [], pinnedNotes: [] };
|
||||||
|
try {
|
||||||
|
data = await api.get('/dashboard');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Dashboard] Ladefehler:', err.message);
|
||||||
|
window.oikos?.showToast('Dashboard konnte nicht vollständig geladen werden.', 'warning');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Widgets rendern
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="dashboard">
|
||||||
|
<div class="dashboard__grid">
|
||||||
|
${renderGreeting(user)}
|
||||||
|
${renderUrgentTasks(data.urgentTasks ?? [])}
|
||||||
|
${renderUpcomingEvents(data.upcomingEvents ?? [])}
|
||||||
|
${renderTodayMeals(data.todayMeals ?? [])}
|
||||||
|
${renderPinnedNotes(data.pinnedNotes ?? [])}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${renderFab()}
|
||||||
|
`;
|
||||||
|
|
||||||
|
wireLinks(container);
|
||||||
|
initFab(container);
|
||||||
|
if (window.lucide) window.lucide.createIcons();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,450 @@
|
|||||||
|
/**
|
||||||
|
* Modul: Dashboard
|
||||||
|
* Zweck: Styles für das Dashboard — Begrüßung, Widget-Grid, alle Widget-Typen, FAB-Speed-Dial
|
||||||
|
* Abhängigkeiten: tokens.css, layout.css
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Dashboard-Layout
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.dashboard {
|
||||||
|
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) {
|
||||||
|
.dashboard {
|
||||||
|
padding: var(--space-8);
|
||||||
|
padding-bottom: var(--space-16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Widget-Grid
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.dashboard__grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.dashboard__grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget--wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.dashboard__grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget--wide {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Begrüßungs-Widget
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.widget-greeting {
|
||||||
|
background: linear-gradient(135deg, var(--color-accent) 0%, #5B9FFF 100%);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-5) var(--space-6);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.widget-greeting {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-greeting__title {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-greeting__date {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Basis-Widget (Card)
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.widget {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-4) var(--space-4) var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget__title {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget__title-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget__link {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget__body {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget__empty {
|
||||||
|
padding: var(--space-6) var(--space-4);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Aufgaben-Widget
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.task-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3) 0;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item:hover {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item__priority {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item__priority--urgent { background-color: var(--color-priority-urgent); }
|
||||||
|
.task-item__priority--high { background-color: var(--color-priority-high); }
|
||||||
|
|
||||||
|
.task-item__content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item__title {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item__meta {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item__meta--overdue {
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item__avatar {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
color: #ffffff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Termine-Widget
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.event-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3) 0;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item:hover {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item__bar {
|
||||||
|
width: 3px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
flex-shrink: 0;
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item__content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item__title {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item__time {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Essen-Widget
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.meal-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3) 0;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-item:hover {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-item__type-badge {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
padding: 2px var(--space-2);
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
background-color: var(--color-accent-light);
|
||||||
|
color: var(--color-accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 72px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-item__title {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Notizen-Widget
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.note-item {
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: var(--space-3);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item__title {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item__content {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Skeleton-Zustände (pro Widget)
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.widget-skeleton {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line {
|
||||||
|
height: 14px;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line--short { width: 40%; }
|
||||||
|
.skeleton-line--medium { width: 65%; }
|
||||||
|
.skeleton-line--full { width: 100%; }
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* FAB Speed-Dial
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.fab-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: calc(var(--nav-height-mobile) + var(--safe-area-inset-bottom) + var(--space-4));
|
||||||
|
right: var(--space-4);
|
||||||
|
z-index: calc(var(--z-nav) - 1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.fab-container {
|
||||||
|
bottom: var(--space-8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab-main {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform var(--transition-base), background-color var(--transition-fast);
|
||||||
|
border: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab-main:hover {
|
||||||
|
background-color: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab-main--open {
|
||||||
|
transform: rotate(45deg);
|
||||||
|
background-color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--space-2);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(8px);
|
||||||
|
transition: opacity var(--transition-base), transform var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab-actions--visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab-action__label {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab-action__btn {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
color: var(--color-accent);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab-action__btn:hover {
|
||||||
|
background-color: var(--color-accent-light);
|
||||||
|
}
|
||||||
@@ -79,6 +79,7 @@ app.use('/api/v1/auth', authRouter);
|
|||||||
|
|
||||||
// Alle weiteren API-Routen erfordern Authentifizierung
|
// Alle weiteren API-Routen erfordern Authentifizierung
|
||||||
app.use('/api/v1', requireAuth);
|
app.use('/api/v1', requireAuth);
|
||||||
|
app.use('/api/v1/dashboard', require('./routes/dashboard'));
|
||||||
app.use('/api/v1/tasks', require('./routes/tasks'));
|
app.use('/api/v1/tasks', require('./routes/tasks'));
|
||||||
app.use('/api/v1/shopping', require('./routes/shopping'));
|
app.use('/api/v1/shopping', require('./routes/shopping'));
|
||||||
app.use('/api/v1/meals', require('./routes/meals'));
|
app.use('/api/v1/meals', require('./routes/meals'));
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Modul: Dashboard
|
||||||
|
* Zweck: Aggregierter Endpoint — liefert Daten aller Dashboard-Widgets in einem Request
|
||||||
|
* Abhängigkeiten: express, server/db.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const db = require('../db');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/dashboard
|
||||||
|
* Liefert aggregierte Daten für alle Dashboard-Widgets.
|
||||||
|
* Jedes Widget-Objekt hat ein eigenes `error`-Feld falls die Abfrage fehlschlägt —
|
||||||
|
* so bricht ein fehlerhaftes Widget nicht das gesamte Dashboard.
|
||||||
|
*
|
||||||
|
* Response: {
|
||||||
|
* upcomingEvents: CalendarEvent[], // Nächste 5 Termine
|
||||||
|
* urgentTasks: Task[], // High/Urgent mit Fälligkeit ≤ 48h
|
||||||
|
* todayMeals: Meal[], // Mahlzeiten für heute
|
||||||
|
* pinnedNotes: Note[], // Angepinnte Notizen (max. 3)
|
||||||
|
* users: User[] // Alle User (für Avatar-Farben)
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
const d = db.get();
|
||||||
|
const result = {};
|
||||||
|
|
||||||
|
// Heute und +48h als ISO-Strings
|
||||||
|
const now = new Date();
|
||||||
|
const todayStr = now.toISOString().slice(0, 10);
|
||||||
|
const deadline48h = new Date(now.getTime() + 48 * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
// Anstehende Termine (nächste 5, ab jetzt)
|
||||||
|
try {
|
||||||
|
result.upcomingEvents = d.prepare(`
|
||||||
|
SELECT
|
||||||
|
ce.*,
|
||||||
|
u.display_name AS assigned_name,
|
||||||
|
u.avatar_color AS assigned_color
|
||||||
|
FROM calendar_events ce
|
||||||
|
LEFT JOIN users u ON ce.assigned_to = u.id
|
||||||
|
WHERE ce.start_datetime >= ?
|
||||||
|
ORDER BY ce.start_datetime ASC
|
||||||
|
LIMIT 5
|
||||||
|
`).all(now.toISOString());
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Dashboard] upcomingEvents-Fehler:', err.message);
|
||||||
|
result.upcomingEvents = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dringende Aufgaben: high/urgent + fällig in ≤ 48h + nicht erledigt
|
||||||
|
try {
|
||||||
|
result.urgentTasks = d.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.priority IN ('high', 'urgent')
|
||||||
|
AND t.status != 'done'
|
||||||
|
AND (t.due_date IS NULL OR t.due_date <= ?)
|
||||||
|
ORDER BY
|
||||||
|
CASE t.priority WHEN 'urgent' THEN 0 ELSE 1 END,
|
||||||
|
t.due_date ASC NULLS LAST
|
||||||
|
LIMIT 10
|
||||||
|
`).all(deadline48h.slice(0, 10));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Dashboard] urgentTasks-Fehler:', err.message);
|
||||||
|
result.urgentTasks = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heutiges Essen
|
||||||
|
try {
|
||||||
|
result.todayMeals = d.prepare(`
|
||||||
|
SELECT * FROM meals
|
||||||
|
WHERE date = ?
|
||||||
|
ORDER BY
|
||||||
|
CASE meal_type
|
||||||
|
WHEN 'breakfast' THEN 0
|
||||||
|
WHEN 'lunch' THEN 1
|
||||||
|
WHEN 'dinner' THEN 2
|
||||||
|
WHEN 'snack' THEN 3
|
||||||
|
END
|
||||||
|
`).all(todayStr);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Dashboard] todayMeals-Fehler:', err.message);
|
||||||
|
result.todayMeals = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Angepinnte Notizen (max. 3)
|
||||||
|
try {
|
||||||
|
result.pinnedNotes = d.prepare(`
|
||||||
|
SELECT n.*, u.display_name AS author_name, u.avatar_color AS author_color
|
||||||
|
FROM notes n
|
||||||
|
LEFT JOIN users u ON n.created_by = u.id
|
||||||
|
WHERE n.pinned = 1
|
||||||
|
ORDER BY n.updated_at DESC
|
||||||
|
LIMIT 3
|
||||||
|
`).all();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Dashboard] pinnedNotes-Fehler:', err.message);
|
||||||
|
result.pinnedNotes = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle User (für Avatar-Farben in Widgets)
|
||||||
|
try {
|
||||||
|
result.users = d.prepare(
|
||||||
|
'SELECT id, display_name, avatar_color FROM users ORDER BY display_name'
|
||||||
|
).all();
|
||||||
|
} catch (err) {
|
||||||
|
result.users = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* Modul: Dashboard-API-Test
|
||||||
|
* Zweck: Validiert die Dashboard-Aggregationsabfragen mit node:sqlite
|
||||||
|
* Ausführen: node --experimental-sqlite test-dashboard.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(condition, msg) {
|
||||||
|
if (!condition) throw new Error(msg || 'Assertion fehlgeschlagen');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// DB aufbauen
|
||||||
|
// --------------------------------------------------------
|
||||||
|
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 einfügen
|
||||||
|
const u1 = db.prepare(`INSERT INTO users (username, display_name, password_hash, avatar_color, role)
|
||||||
|
VALUES ('admin', 'Anna Admin', 'x', '#007AFF', 'admin')`).run();
|
||||||
|
const u2 = db.prepare(`INSERT INTO users (username, display_name, password_hash, avatar_color)
|
||||||
|
VALUES ('max', 'Max Muster', 'x', '#34C759')`).run();
|
||||||
|
|
||||||
|
const uid1 = u1.lastInsertRowid;
|
||||||
|
const uid2 = u2.lastInsertRowid;
|
||||||
|
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const tomorrow = new Date(Date.now() + 86400000).toISOString().slice(0, 10);
|
||||||
|
const inOneHour = new Date(Date.now() + 3600000).toISOString();
|
||||||
|
const in30h = new Date(Date.now() + 30 * 3600000).toISOString().slice(0, 10);
|
||||||
|
const in72h = new Date(Date.now() + 72 * 3600000).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// Aufgaben
|
||||||
|
db.prepare(`INSERT INTO tasks (title, priority, status, due_date, created_by, assigned_to)
|
||||||
|
VALUES ('Urgent Task', 'urgent', 'open', ?, ?, ?)`).run(today, uid1, uid2);
|
||||||
|
db.prepare(`INSERT INTO tasks (title, priority, status, due_date, created_by)
|
||||||
|
VALUES ('High Task morgen', 'high', 'open', ?, ?)`).run(tomorrow, uid1);
|
||||||
|
db.prepare(`INSERT INTO tasks (title, priority, status, due_date, created_by)
|
||||||
|
VALUES ('High Task in 3 Tagen', 'high', 'open', ?, ?)`).run(in72h, uid1);
|
||||||
|
db.prepare(`INSERT INTO tasks (title, priority, status, due_date, created_by)
|
||||||
|
VALUES ('Done Task', 'urgent', 'done', ?, ?)`).run(today, uid1);
|
||||||
|
|
||||||
|
// Kalender-Events
|
||||||
|
db.prepare(`INSERT INTO calendar_events (title, start_datetime, created_by, assigned_to, color)
|
||||||
|
VALUES ('Morgen-Meeting', ?, ?, ?, '#007AFF')`).run(inOneHour, uid1, uid2);
|
||||||
|
db.prepare(`INSERT INTO calendar_events (title, start_datetime, created_by)
|
||||||
|
VALUES ('Event in 3 Tagen', ?, ?)`).run(in72h + 'T10:00:00Z', uid1);
|
||||||
|
|
||||||
|
// Mahlzeiten
|
||||||
|
db.prepare(`INSERT INTO meals (date, meal_type, title, created_by)
|
||||||
|
VALUES (?, 'breakfast', 'Haferbrei', ?)`).run(today, uid1);
|
||||||
|
db.prepare(`INSERT INTO meals (date, meal_type, title, created_by)
|
||||||
|
VALUES (?, 'dinner', 'Pasta', ?)`).run(today, uid1);
|
||||||
|
db.prepare(`INSERT INTO meals (date, meal_type, title, created_by)
|
||||||
|
VALUES (?, 'lunch', 'Salat morgen', ?)`).run(tomorrow, uid1);
|
||||||
|
|
||||||
|
// Notizen
|
||||||
|
db.prepare(`INSERT INTO notes (content, title, pinned, color, created_by)
|
||||||
|
VALUES ('Wichtige Info', 'Pinnwand-Notiz', 1, '#FFEB3B', ?)`).run(uid1);
|
||||||
|
db.prepare(`INSERT INTO notes (content, pinned, color, created_by)
|
||||||
|
VALUES ('Nicht angepinnt', 0, '#E3F2FF', ?)`).run(uid1);
|
||||||
|
|
||||||
|
console.log('\n[Dashboard-Test] API-Abfragen\n');
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Tests: Dringende Aufgaben
|
||||||
|
// --------------------------------------------------------
|
||||||
|
const deadline48h = new Date(Date.now() + 48 * 3600000).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
test('Dringende Aufgaben: nur high/urgent mit Fälligkeit ≤ 48h und nicht done', () => {
|
||||||
|
const tasks = 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.priority IN ('high', 'urgent')
|
||||||
|
AND t.status != 'done'
|
||||||
|
AND (t.due_date IS NULL OR t.due_date <= ?)
|
||||||
|
ORDER BY CASE t.priority WHEN 'urgent' THEN 0 ELSE 1 END, t.due_date ASC
|
||||||
|
LIMIT 10
|
||||||
|
`).all(deadline48h);
|
||||||
|
|
||||||
|
assert(tasks.length === 2, `Erwartet 2 Aufgaben, erhalten ${tasks.length}`);
|
||||||
|
assert(tasks[0].priority === 'urgent', 'Urgent zuerst');
|
||||||
|
assert(tasks[0].assigned_name === 'Max Muster', 'assigned_name korrekt');
|
||||||
|
assert(tasks[0].assigned_color === '#34C759', 'assigned_color korrekt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Dringende Aufgaben: erledigte Aufgaben werden nicht angezeigt', () => {
|
||||||
|
const tasks = db.prepare(`
|
||||||
|
SELECT * FROM tasks
|
||||||
|
WHERE priority IN ('high', 'urgent') AND status != 'done' AND due_date <= ?
|
||||||
|
`).all(deadline48h);
|
||||||
|
const doneTask = tasks.find((t) => t.title === 'Done Task');
|
||||||
|
assert(!doneTask, 'Erledigte Aufgaben sollten gefiltert sein');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Dringende Aufgaben: Task mit Fälligkeit in 3 Tagen wird ausgeschlossen', () => {
|
||||||
|
const tasks = db.prepare(`
|
||||||
|
SELECT * FROM tasks
|
||||||
|
WHERE priority IN ('high', 'urgent') AND status != 'done' AND due_date <= ?
|
||||||
|
`).all(deadline48h);
|
||||||
|
const farTask = tasks.find((t) => t.title === 'High Task in 3 Tagen');
|
||||||
|
assert(!farTask, 'Aufgabe in 72h sollte nicht erscheinen');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Tests: Anstehende Termine
|
||||||
|
// --------------------------------------------------------
|
||||||
|
test('Anstehende Termine: zukünftige Events, sortiert, max 5', () => {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const events = db.prepare(`
|
||||||
|
SELECT ce.*, u.display_name AS assigned_name, u.avatar_color AS assigned_color
|
||||||
|
FROM calendar_events ce
|
||||||
|
LEFT JOIN users u ON ce.assigned_to = u.id
|
||||||
|
WHERE ce.start_datetime >= ?
|
||||||
|
ORDER BY ce.start_datetime ASC
|
||||||
|
LIMIT 5
|
||||||
|
`).all(now);
|
||||||
|
|
||||||
|
assert(events.length === 2, `Erwartet 2 Events, erhalten ${events.length}`);
|
||||||
|
assert(events[0].title === 'Morgen-Meeting', 'Erstes Event ist das nächste');
|
||||||
|
assert(events[0].assigned_color === '#34C759', 'assigned_color vom Join');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Tests: Heutige Mahlzeiten
|
||||||
|
// --------------------------------------------------------
|
||||||
|
test('Heutige Mahlzeiten: nur heute, in korrekter Reihenfolge', () => {
|
||||||
|
const meals = db.prepare(`
|
||||||
|
SELECT * FROM meals WHERE date = ?
|
||||||
|
ORDER BY CASE meal_type
|
||||||
|
WHEN 'breakfast' THEN 0 WHEN 'lunch' THEN 1
|
||||||
|
WHEN 'dinner' THEN 2 WHEN 'snack' THEN 3 END
|
||||||
|
`).all(today);
|
||||||
|
|
||||||
|
assert(meals.length === 2, `Erwartet 2 Mahlzeiten, erhalten ${meals.length}`);
|
||||||
|
assert(meals[0].meal_type === 'breakfast', 'Frühstück zuerst');
|
||||||
|
assert(meals[1].meal_type === 'dinner', 'Abendessen danach');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Heutige Mahlzeiten: morgige Mahlzeit nicht enthalten', () => {
|
||||||
|
const meals = db.prepare(`SELECT * FROM meals WHERE date = ?`).all(today);
|
||||||
|
const wrongMeal = meals.find((m) => m.title === 'Salat morgen');
|
||||||
|
assert(!wrongMeal, 'Morgige Mahlzeit sollte nicht erscheinen');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Tests: Angepinnte Notizen
|
||||||
|
// --------------------------------------------------------
|
||||||
|
test('Angepinnte Notizen: nur pinned=1, max 3', () => {
|
||||||
|
const notes = db.prepare(`
|
||||||
|
SELECT n.*, u.display_name AS author_name, u.avatar_color AS author_color
|
||||||
|
FROM notes n
|
||||||
|
LEFT JOIN users u ON n.created_by = u.id
|
||||||
|
WHERE n.pinned = 1
|
||||||
|
ORDER BY n.updated_at DESC
|
||||||
|
LIMIT 3
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
assert(notes.length === 1, `Erwartet 1 Notiz, erhalten ${notes.length}`);
|
||||||
|
assert(notes[0].title === 'Pinnwand-Notiz', 'Korrekte Notiz');
|
||||||
|
assert(notes[0].author_name === 'Anna Admin', 'author_name vom Join');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Angepinnte Notizen: nicht angepinnte werden ausgeschlossen', () => {
|
||||||
|
const notes = db.prepare(`SELECT * FROM notes WHERE pinned = 1`).all();
|
||||||
|
const unpinned = notes.find((n) => n.content === 'Nicht angepinnt');
|
||||||
|
assert(!unpinned, 'Nicht angepinnte Notiz sollte gefiltert sein');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Ergebnis
|
||||||
|
// --------------------------------------------------------
|
||||||
|
console.log(`\n[Dashboard-Test] Ergebnis: ${passed} bestanden, ${failed} fehlgeschlagen\n`);
|
||||||
|
if (failed > 0) process.exit(1);
|
||||||
Reference in New Issue
Block a user