/** * Modul: Dashboard * Zweck: Startseite mit Begrüßung, Terminen, Aufgaben, Essen, Notizen und FAB * Abhängigkeiten: /api.js */ import { api } from '/api.js'; import { t, formatDate, formatTime, getLocale } from '/i18n.js'; import { esc } from '/utils/html.js'; // Hält den AbortController des aktuellen FAB-Listeners - wird bei jedem render() erneuert. let _fabController = null; // -------------------------------------------------------- // Hilfsfunktionen // -------------------------------------------------------- function greeting(displayName) { const h = new Date().getHours(); if (h < 12) return t('dashboard.greetingMorning', { name: esc(displayName) }); if (h < 18) return t('dashboard.greetingDay', { name: esc(displayName) }); return t('dashboard.greetingEvening', { name: esc(displayName) }); } 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() ? t('common.today') : d.toDateString() === tomorrow.toDateString() ? t('common.tomorrow') : formatDate(d); const timeStr = formatTime(d); const suffix = t('calendar.timeSuffix'); return `${dateStr}, ${timeStr}${suffix ? ' ' + suffix : ''}`.trim(); } 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: t('dashboard.overdue'), overdue: true }; if (diffH < 24) return { text: t('dashboard.dueSoon'), overdue: false }; if (diffH < 48) return { text: t('dashboard.dueTomorrow'), overdue: false }; return { text: formatDate(due), overdue: false, }; } const PRIORITY_LABELS = () => ({ urgent: t('tasks.priorityUrgent'), high: t('tasks.priorityHigh'), medium: t('tasks.priorityMedium'), low: t('tasks.priorityLow'), }); const MEAL_LABELS = () => ({ breakfast: t('meals.typeBreakfast'), lunch: t('meals.typeLunch'), dinner: t('meals.typeDinner'), snack: t('meals.typeSnack'), }); 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) { linkLabel = linkLabel ?? t('dashboard.allLink'); const badge = count != null ? `${count}` : ''; return `
${title} ${badge} ${linkLabel}
`; } // -------------------------------------------------------- // Skeleton // -------------------------------------------------------- function skeletonWidget(lines = 3) { const lineHtml = Array.from({ length: lines }, (_, i) => `
`).join(''); return `
${lineHtml}
`; } // -------------------------------------------------------- // Widget-Renderer // -------------------------------------------------------- function renderGreeting(user, stats = {}) { const { urgentCount = 0, todayEventCount = 0, todayMealTitle = null } = stats; const chipIcon = 'width:12px;height:12px;flex-shrink:0;'; const statChips = []; if (urgentCount > 0) statChips.push(` ${urgentCount > 1 ? t('dashboard.urgentTasksChipPlural', { count: urgentCount }) : t('dashboard.urgentTasksChip', { count: urgentCount })} `); if (todayEventCount > 0) statChips.push(` ${todayEventCount > 1 ? t('dashboard.eventsChipPlural', { count: todayEventCount }) : t('dashboard.eventsChip', { count: todayEventCount })} `); if (todayMealTitle) statChips.push(` ${t('dashboard.todayMealChip', { title: esc(todayMealTitle) })} `); return `
${greeting(user.display_name)}
${formatDate(new Date())}
${statChips.length ? `
${statChips.join('')}
` : ''}
`; } function renderUrgentTasks(tasks) { if (!tasks.length) { return `
${widgetHeader('check-square', t('nav.tasks'), 0, '/tasks')}
${t('dashboard.allDone')}
`; } const items = tasks.map((t) => { const due = formatDueDate(t.due_date); return `
${t.priority !== 'none' ? `` : ''} ${PRIORITY_LABELS()[t.priority] ?? t.priority}
${esc(t.title)}
${due ? `
${due.text}
` : ''}
${t.assigned_color ? `
${esc(initials(t.assigned_name || ''))}
` : ''}
`; }).join(''); return `
${widgetHeader('check-square', t('nav.tasks'), tasks.length, '/tasks')}
${items}
`; } function renderUpcomingEvents(events) { if (!events.length) { return `
${widgetHeader('calendar', t('nav.calendar'), 0, '/calendar')}
${t('dashboard.noEvents')}
`; } const today = new Date().toDateString(); const items = events.map((e) => { const d = new Date(e.start_datetime); const isToday = d.toDateString() === today; const _suffix = t('calendar.timeSuffix'); const timeStr = e.all_day ? t('dashboard.allDay') : `${formatTime(d)}${_suffix ? ' ' + _suffix : ''}`.trim(); return `
${esc(e.title)}
${isToday ? t('common.today') : formatDateTime(e.start_datetime).split(',')[0]} ${timeStr} ${e.location ? ` · ${esc(e.location)}` : ''}
`; }).join(''); return `
${widgetHeader('calendar', t('nav.calendar'), events.length, '/calendar')}
${items}
`; } function renderTodayMeals(meals) { const MEAL_ORDER = ['breakfast', 'lunch', 'dinner', 'snack']; const mealLabels = MEAL_LABELS(); const slots = MEAL_ORDER.map((type) => { const meal = meals.find((m) => m.meal_type === type); return `
${mealLabels[type]}
${meal ? esc(meal.title) : '-'}
`; }).join(''); return `
${widgetHeader('utensils', t('dashboard.todayMeals'), null, '/meals', t('dashboard.weekLink'))}
${slots}
`; } function renderPinnedNotes(notes) { if (!notes.length) { return `
${widgetHeader('pin', t('nav.notes'), 0, '/notes')}
${t('dashboard.noPinnedNotes')}
`; } const items = notes.map((n) => `
${n.title ? `
${esc(n.title)}
` : ''}
${esc(n.content)}
`).join(''); return `
${widgetHeader('pin', t('nav.notes'), notes.length, '/notes')}
${items}
`; } // -------------------------------------------------------- // Shopping-Widget // -------------------------------------------------------- function renderShoppingLists(lists) { if (!lists.length) return ''; const totalOpen = lists.reduce((sum, l) => sum + l.open_count, 0); const listsHtml = lists.map((list) => { const progress = list.total_count > 0 ? Math.round(((list.total_count - list.open_count) / list.total_count) * 100) : 0; const itemsHtml = list.items.map((item) => `
${esc(item.name)} ${item.quantity ? `${esc(item.quantity)}` : ''}
`).join(''); const moreCount = list.open_count - list.items.length; return `
${esc(list.name)} ${list.total_count - list.open_count}/${list.total_count}
${itemsHtml} ${moreCount > 0 ? `
${t('dashboard.shoppingMore', { count: moreCount })}
` : ''}
`; }).join(''); return `
${widgetHeader('shopping-cart', t('nav.shopping'), totalOpen, '/shopping')}
${listsHtml}
`; } // -------------------------------------------------------- // Wetter-Widget // -------------------------------------------------------- const WEATHER_ICON_BASE = '/api/v1/weather/icon/'; function renderWeatherWidget(weather) { if (!weather) return ''; const { city, current, forecast } = weather; const forecastHtml = forecast.map((d, i) => { const date = new Date(d.date + 'T12:00:00'); const label = new Intl.DateTimeFormat(getLocale(), { weekday: 'short' }).format(date); const extraCls = i >= 3 ? ' weather-forecast__day--extended' : ''; return `
${label}
${esc(d.desc)}
${d.temp_max}° ${d.temp_min}°
`; }).join(''); return `
${esc(current.temp)}°C
${esc(current.desc)}
${esc(city)}
${t('dashboard.weatherFeelsLike', { temp: current.feels_like, humidity: current.humidity, wind: current.wind_speed })}
${esc(current.desc)}
${forecast.length ? `
${forecastHtml}
` : ''}
`; } // -------------------------------------------------------- // FAB Speed-Dial // -------------------------------------------------------- const FAB_ACTIONS = () => [ { route: '/tasks', label: t('dashboard.fabTask'), icon: 'check-square' }, { route: '/calendar', label: t('dashboard.fabCalendar'), icon: 'calendar-plus' }, { route: '/shopping', label: t('dashboard.fabShopping'), icon: 'shopping-cart' }, { route: '/notes', label: t('dashboard.fabNote'), icon: 'sticky-note' }, ]; function renderFab() { const actionsHtml = FAB_ACTIONS().map((a) => `
${a.label}
`).join(''); return `
`; } function initFab(container, signal) { 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); }, { signal }); } // -------------------------------------------------------- // Navigations-Links verdrahten // -------------------------------------------------------- function wireLinks(container) { container.querySelectorAll('[data-route]').forEach((el) => { 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(); }); } 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 }) { _fabController?.abort(); _fabController = new AbortController(); container.innerHTML = `
${greeting(user.display_name)}
${formatDate(new Date())}
${skeletonWidget(3)} ${skeletonWidget(3)} ${skeletonWidget(2)} ${skeletonWidget(3)}
${renderFab()} `; let data = { upcomingEvents: [], urgentTasks: [], todayMeals: [], pinnedNotes: [], shoppingLists: [] }; let weather = null; try { const [dashRes, weatherRes] = await Promise.all([ api.get('/dashboard'), api.get('/weather').catch(() => ({ data: null })), ]); data = dashRes; weather = weatherRes.data ?? null; } catch (err) { console.error('[Dashboard] Ladefehler:', err.message); window.oikos?.showToast(t('dashboard.loadError'), 'warning'); } const today = new Date().toDateString(); const stats = { urgentCount: (data.urgentTasks ?? []).filter((t) => t.priority === 'urgent' || t.priority === 'high').length, 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 = `

${t('dashboard.title')}

${renderGreeting(user, stats)} ${renderWeatherWidget(weather)} ${renderUrgentTasks(data.urgentTasks ?? [])} ${renderUpcomingEvents(data.upcomingEvents ?? [])} ${renderShoppingLists(data.shoppingLists ?? [])} ${renderTodayMeals(data.todayMeals ?? [])} ${renderPinnedNotes(data.pinnedNotes ?? [])}
${renderFab()} `; wireLinks(container); initFab(container, _fabController.signal); if (window.lucide) window.lucide.createIcons(); // Wetter-Refresh: Button + 30-Minuten-Interval const refreshBtn = container.querySelector('#weather-refresh-btn'); if (refreshBtn) { const doWeatherRefresh = async () => { refreshBtn.disabled = true; refreshBtn.classList.add('weather-widget__refresh--spinning'); try { const res = await api.get('/weather').catch(() => ({ data: null })); const wWidget = container.querySelector('#weather-widget'); if (wWidget) { const fresh = renderWeatherWidget(res.data ?? null); wWidget.outerHTML = fresh; const newWidget = container.querySelector('#weather-widget'); if (newWidget && window.lucide) window.lucide.createIcons({ el: newWidget }); wireWeatherRefresh(container); } } catch { /* silently ignore */ } }; refreshBtn.addEventListener('click', doWeatherRefresh, { signal: _fabController.signal }); // 30-Minuten Auto-Refresh - abortiert wenn Seite verlassen wird const timerId = setInterval(doWeatherRefresh, 30 * 60 * 1000); _fabController.signal.addEventListener('abort', () => clearInterval(timerId)); } } function wireWeatherRefresh(container) { const refreshBtn = container.querySelector('#weather-refresh-btn'); if (!refreshBtn) return; const doWeatherRefresh = async () => { refreshBtn.disabled = true; refreshBtn.classList.add('weather-widget__refresh--spinning'); try { const res = await api.get('/weather').catch(() => ({ data: null })); const wWidget = container.querySelector('#weather-widget'); if (wWidget) { wWidget.outerHTML = renderWeatherWidget(res.data ?? null); const newWidget = container.querySelector('#weather-widget'); if (newWidget && window.lucide) window.lucide.createIcons({ el: newWidget }); wireWeatherRefresh(container); } } catch { /* silently ignore */ } }; refreshBtn.addEventListener('click', doWeatherRefresh, { signal: _fabController.signal }); }