feat: Dashboard-Widgets mit dynamischen Daten und neuem Design

- Begrüßungs-Widget mit Stats-Chips (dringende Aufgaben, heutige Termine, Mittagessen)
- Aufgaben- und Termine-Widgets mit Count-Badge im Header
- Essen-Widget als 4-Slot-Raster (Frühstück/Mittagessen/Abendessen/Snack) mit Lucide-Icons
- Notizen als Kachel-Grid statt Liste
- event-time-badge, widget__badge, greeting-chip, meal-slots, notes-grid-widget CSS
- Hover-Lift-Effekt auf Widgets (Desktop)
- Widget-Empty-States mit zentrierten Icons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ulsklyc
2026-03-25 13:18:42 +01:00
parent 55a0371505
commit 60ecc1f3d9
2 changed files with 326 additions and 125 deletions
+125 -88
View File
@@ -62,10 +62,35 @@ const MEAL_LABELS = {
snack: 'Snack',
};
const MEAL_ICONS = {
breakfast: 'sunrise',
lunch: 'sun',
dinner: 'moon',
snack: 'apple',
};
function initials(name = '') {
return name.split(' ').map((w) => w[0]).join('').slice(0, 2).toUpperCase();
}
function widgetHeader(icon, title, count, linkHref, linkLabel = 'Alle') {
const badge = count != null
? `<span class="widget__badge">${count}</span>`
: '';
return `
<div class="widget__header">
<span class="widget__title">
<i data-lucide="${icon}" class="widget__title-icon" aria-hidden="true"></i>
${title}
${badge}
</span>
<a href="${linkHref}" data-route="${linkHref}" class="widget__link">
${linkLabel} <i data-lucide="chevron-right" style="width:12px;height:12px;vertical-align:-2px;"></i>
</a>
</div>
`;
}
// --------------------------------------------------------
// Skeleton
// --------------------------------------------------------
@@ -88,37 +113,52 @@ function skeletonWidget(lines = 3) {
// Widget-Renderer
// --------------------------------------------------------
function renderGreeting(user) {
function renderGreeting(user, stats = {}) {
const { urgentCount = 0, todayEventCount = 0, todayMealTitle = null } = stats;
const statChips = [];
if (urgentCount > 0)
statChips.push(`<span class="greeting-chip greeting-chip--warn">
<i data-lucide="alert-circle" style="width:12px;height:12px;"></i>
${urgentCount} dring. Aufgabe${urgentCount > 1 ? 'n' : ''}
</span>`);
if (todayEventCount > 0)
statChips.push(`<span class="greeting-chip">
<i data-lucide="calendar" style="width:12px;height:12px;"></i>
${todayEventCount} Termin${todayEventCount > 1 ? 'e' : ''} heute
</span>`);
if (todayMealTitle)
statChips.push(`<span class="greeting-chip">
<i data-lucide="utensils" style="width:12px;height:12px;"></i>
Heute: ${todayMealTitle}
</span>`);
return `
<div class="widget-greeting">
<div class="widget-greeting__content">
<div class="widget-greeting__title">${greeting(user.display_name)}</div>
<div class="widget-greeting__date">${formatDate()}</div>
${statChips.length ? `<div class="widget-greeting__chips">${statChips.join('')}</div>` : ''}
</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>
return `<div class="widget">
${widgetHeader('check-square', 'Aufgaben', 0, '/tasks')}
<div class="widget__empty">
<i data-lucide="check-circle" style="width:32px;height:32px;color:var(--color-success);margin-bottom:var(--space-2);"></i>
<div>Alles erledigt</div>
</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" data-route="/tasks" role="button" tabindex="0">
<div class="task-item__priority task-item__priority--${t.priority}"></div>
<div class="task-item__content">
<div class="task-item__title">${t.title}</div>
@@ -126,106 +166,97 @@ function renderUrgentTasks(tasks) {
</div>
${t.assigned_color ? `
<div class="task-item__avatar" style="background-color:${t.assigned_color}"
title="${t.assigned_name || ''}">
${initials(t.assigned_name || '')}
</div>` : ''}
title="${t.assigned_name || ''}">${initials(t.assigned_name || '')}</div>` : ''}
</div>
`;
}).join('');
return `<div class="widget">${header}<div class="widget__body">${items}</div></div>`;
return `<div class="widget">
${widgetHeader('check-square', 'Aufgaben', tasks.length, '/tasks')}
<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>
return `<div class="widget">
${widgetHeader('calendar', 'Termine', 0, '/calendar')}
<div class="widget__empty">
<i data-lucide="calendar-check" style="width:32px;height:32px;color:var(--color-text-disabled);margin-bottom:var(--space-2);"></i>
<div>Keine Termine</div>
</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>
const today = new Date().toDateString();
const items = events.map((e) => {
const d = new Date(e.start_datetime);
const isToday = d.toDateString() === today;
const timeStr = e.all_day ? 'Ganztägig' : d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) + ' Uhr';
return `
<div class="event-item" data-route="/calendar" role="button" tabindex="0">
<div class="event-item__bar" style="background-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)}
<span class="event-time-badge ${isToday ? 'event-time-badge--today' : ''}">${isToday ? 'Heute' : formatDateTime(e.start_datetime).split(',')[0]}</span>
${timeStr}
${e.location ? ` · ${e.location}` : ''}
</div>
</div>
</div>
`).join('');
`;
}).join('');
return `<div class="widget">${header}<div class="widget__body">${items}</div></div>`;
return `<div class="widget">
${widgetHeader('calendar', 'Termine', events.length, '/calendar')}
<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>
const MEAL_ORDER = ['breakfast', 'lunch', 'dinner', 'snack'];
const slots = MEAL_ORDER.map((type) => {
const meal = meals.find((m) => m.meal_type === type);
return `
<div class="meal-slot ${meal ? 'meal-slot--filled' : ''}" data-route="/meals" role="button" tabindex="0">
<i data-lucide="${MEAL_ICONS[type]}" class="meal-slot__icon" aria-hidden="true"></i>
<div class="meal-slot__type">${MEAL_LABELS[type]}</div>
<div class="meal-slot__title">${meal ? meal.title : '—'}</div>
</div>
`;
}).join('');
if (!meals.length) {
return `<div class="widget">${header}
<div class="widget__empty">Kein Essensplan für heute.</div>
return `<div class="widget widget--meals">
${widgetHeader('utensils', 'Heute essen', null, '/meals', 'Woche')}
<div class="meal-slots">${slots}</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>
return `<div class="widget">
${widgetHeader('pin', 'Pinnwand', 0, '/notes')}
<div class="widget__empty">
<i data-lucide="sticky-note" style="width:32px;height:32px;color:var(--color-text-disabled);margin-bottom:var(--space-2);"></i>
<div>Keine angepinnten Notizen</div>
</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 : ''}">
style="--note-color:${n.color};">
${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>`;
return `<div class="widget widget--wide">
${widgetHeader('pin', 'Pinnwand', notes.length, '/notes')}
<div class="notes-grid-widget">${items}</div>
</div>`;
}
// --------------------------------------------------------
@@ -234,12 +265,8 @@ function renderPinnedNotes(notes) {
const WEATHER_ICON_BASE = 'https://openweathermap.org/img/wn/';
function weatherIconUrl(icon) {
return `${WEATHER_ICON_BASE}${icon}@2x.png`;
}
function renderWeatherWidget(weather) {
if (!weather) return ''; // Kein API-Key → Widget ausblenden
if (!weather) return '';
const { city, current, forecast } = weather;
@@ -249,7 +276,7 @@ function renderWeatherWidget(weather) {
return `
<div class="weather-forecast__day">
<div class="weather-forecast__label">${label}</div>
<img class="weather-forecast__icon" src="${weatherIconUrl(d.icon)}"
<img class="weather-forecast__icon" src="${WEATHER_ICON_BASE}${d.icon}@2x.png"
alt="${d.desc}" width="32" height="32" loading="lazy">
<div class="weather-forecast__temps">
<span class="weather-forecast__high">${d.temp_max}°</span>
@@ -266,10 +293,10 @@ function renderWeatherWidget(weather) {
<div class="weather-widget__desc">${current.desc}</div>
<div class="weather-widget__city">${city}</div>
<div class="weather-widget__meta">
Gefühlt ${current.feels_like}° · ${current.humidity}% Luftfeuchtigkeit · Wind ${current.wind_speed} km/h
Gefühlt ${current.feels_like}° · ${current.humidity}% · Wind ${current.wind_speed} km/h
</div>
</div>
<img class="weather-widget__icon" src="${weatherIconUrl(current.icon)}"
<img class="weather-widget__icon" src="${WEATHER_ICON_BASE}${current.icon}@2x.png"
alt="${current.desc}" width="80" height="80" loading="lazy">
</div>
${forecast.length ? `<div class="weather-forecast">${forecastHtml}</div>` : ''}
@@ -348,7 +375,7 @@ function initFab(container) {
function wireLinks(container) {
container.querySelectorAll('[data-route]').forEach((el) => {
if (el.id === 'fab-main' || el.closest('#fab-actions')) return; // FAB separat
if (el.id === 'fab-main' || el.closest('#fab-actions')) return;
const go = () => window.oikos.navigate(el.dataset.route);
if (el.tagName === 'A') {
el.addEventListener('click', (e) => { e.preventDefault(); go(); });
@@ -366,14 +393,15 @@ function wireLinks(container) {
// --------------------------------------------------------
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__content">
<div class="widget-greeting__title">${greeting(user.display_name)}</div>
<div class="widget-greeting__date">${formatDate()}</div>
</div>
</div>
${skeletonWidget(3)}
${skeletonWidget(3)}
${skeletonWidget(2)}
@@ -384,7 +412,6 @@ export async function render(container, { user }) {
`;
initFab(container);
// Daten laden (Dashboard + Wetter parallel)
let data = { upcomingEvents: [], urgentTasks: [], todayMeals: [], pinnedNotes: [] };
let weather = null;
try {
@@ -399,11 +426,21 @@ export async function render(container, { user }) {
window.oikos?.showToast('Dashboard konnte nicht vollständig geladen werden.', 'warning');
}
// Widgets rendern
const today = new Date().toDateString();
const stats = {
urgentCount: data.urgentTasks?.length ?? 0,
todayEventCount: (data.upcomingEvents ?? []).filter((e) =>
new Date(e.start_datetime).toDateString() === today
).length,
todayMealTitle: (data.todayMeals ?? []).find((m) => m.meal_type === 'lunch')?.title
?? (data.todayMeals ?? [])[0]?.title
?? null,
};
container.innerHTML = `
<div class="dashboard">
<div class="dashboard__grid">
${renderGreeting(user)}
${renderGreeting(user, stats)}
${renderWeatherWidget(weather)}
${renderUrgentTasks(data.urgentTasks ?? [])}
${renderUpcomingEvents(data.upcomingEvents ?? [])}
+190 -26
View File
@@ -58,18 +58,18 @@
border-radius: var(--radius-md);
padding: var(--space-5) var(--space-6);
color: #ffffff;
grid-column: 1 / -1;
}
@media (min-width: 768px) {
.widget-greeting {
grid-column: 1 / -1;
}
.widget-greeting__content {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.widget-greeting__title {
font-size: var(--text-2xl);
font-weight: var(--font-weight-bold);
margin-bottom: var(--space-1);
}
.widget-greeting__date {
@@ -77,6 +77,30 @@
opacity: 0.85;
}
.widget-greeting__chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-3);
}
.greeting-chip {
display: inline-flex;
align-items: center;
gap: 4px;
background: rgba(255, 255, 255, 0.2);
color: #ffffff;
font-size: var(--text-xs);
font-weight: var(--font-weight-medium);
padding: 3px var(--space-2);
border-radius: var(--radius-full);
backdrop-filter: blur(4px);
}
.greeting-chip--warn {
background: rgba(255, 59, 48, 0.35);
}
/* --------------------------------------------------------
* Basis-Widget (Card)
* -------------------------------------------------------- */
@@ -116,6 +140,24 @@
font-size: var(--text-sm);
color: var(--color-accent);
font-weight: var(--font-weight-medium);
display: inline-flex;
align-items: center;
gap: 2px;
}
.widget__badge {
display: inline-flex;
align-items: center;
justify-content: center;
background-color: var(--color-accent);
color: #ffffff;
font-size: 10px;
font-weight: var(--font-weight-bold);
min-width: 18px;
height: 18px;
padding: 0 4px;
border-radius: var(--radius-full);
line-height: 1;
}
.widget__body {
@@ -128,6 +170,21 @@
text-align: center;
color: var(--color-text-secondary);
font-size: var(--text-sm);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
}
/* Widget hover lift (desktop) */
@media (min-width: 1024px) {
.widget {
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
}
.widget:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
}
/* --------------------------------------------------------
@@ -246,10 +303,137 @@
font-size: var(--text-xs);
color: var(--color-text-secondary);
margin-top: 2px;
display: flex;
align-items: center;
gap: var(--space-1);
}
.event-time-badge {
font-size: 10px;
font-weight: var(--font-weight-semibold);
padding: 1px 5px;
border-radius: var(--radius-full);
background-color: var(--color-surface-2);
color: var(--color-text-secondary);
}
.event-time-badge--today {
background-color: var(--color-accent-light);
color: var(--color-accent);
}
/* --------------------------------------------------------
* Essen-Widget
* Essen-Widget (Slot-Grid)
* -------------------------------------------------------- */
.widget--meals {
overflow: visible;
}
.meal-slots {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
background-color: var(--color-border);
}
.meal-slot {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-1);
padding: var(--space-3) var(--space-2);
background-color: var(--color-surface);
cursor: pointer;
transition: background-color var(--transition-fast);
text-align: center;
min-height: 88px;
}
.meal-slot:hover {
background-color: var(--color-surface-2);
}
.meal-slot:first-child {
border-bottom-left-radius: var(--radius-md);
}
.meal-slot:last-child {
border-bottom-right-radius: var(--radius-md);
}
.meal-slot__icon {
width: 20px;
height: 20px;
color: var(--color-text-disabled);
flex-shrink: 0;
}
.meal-slot--filled .meal-slot__icon {
color: var(--color-accent);
}
.meal-slot__type {
font-size: 10px;
font-weight: var(--font-weight-semibold);
color: var(--color-text-disabled);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.meal-slot--filled .meal-slot__type {
color: var(--color-text-secondary);
}
.meal-slot__title {
font-size: var(--text-xs);
color: var(--color-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
padding: 0 var(--space-1);
}
.meal-slot--filled .meal-slot__title {
color: var(--color-text-primary);
font-weight: var(--font-weight-medium);
}
/* --------------------------------------------------------
* Notizen-Grid-Widget
* -------------------------------------------------------- */
.notes-grid-widget {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
}
@media (min-width: 1024px) {
.notes-grid-widget {
grid-template-columns: repeat(3, 1fr);
}
}
.note-item {
border-radius: var(--radius-sm);
padding: var(--space-3);
cursor: pointer;
transition: opacity var(--transition-fast), transform var(--transition-fast);
border-left: 3px solid var(--note-color, var(--color-accent));
background-color: var(--color-surface-2);
}
.note-item:hover {
opacity: 0.8;
transform: translateY(-1px);
}
/* legacy (old .note-item had margin-bottom, now grid handles gap) */
/* --------------------------------------------------------
* Alte Essen-Listen-Styles (Fallback, nicht mehr primär)
* -------------------------------------------------------- */
.meal-item {
display: flex;
@@ -290,26 +474,6 @@
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);