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:
ulsklyc
2026-03-24 14:42:08 +01:00
parent d49cbe33b3
commit 6d8763bbb9
7 changed files with 1129 additions and 14 deletions
+1
View File
@@ -16,6 +16,7 @@
<link rel="stylesheet" href="/styles/reset.css" />
<link rel="stylesheet" href="/styles/layout.css" />
<link rel="stylesheet" href="/styles/login.css" />
<link rel="stylesheet" href="/styles/dashboard.css" />
<!-- Lucide Icons (CDN) -->
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
+353 -13
View File
@@ -1,25 +1,365 @@
/**
* Modul: Dashboard
* Zweck: Seite für das Dashboard-Modul
* Zweck: Startseite mit Begrüßung, Terminen, Aufgaben, Essen, Notizen und FAB
* 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">Dashboard</h1>
// --------------------------------------------------------
// Hilfsfunktionen
// --------------------------------------------------------
function greeting(displayName) {
const h = new Date().getHours();
const tageszeit = h < 12 ? 'Morgen' : h < 18 ? 'Tag' : 'Abend';
return `Guten ${tageszeit}, ${displayName}`;
}
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 class="empty-state">
<div class="empty-state__title">Kommt bald.</div>
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
`;
}).join('');
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>
`;
}
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();
}
+450
View File
@@ -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);
}