/** * 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, fmtLocation } from '/utils/html.js'; import { openModal, closeModal } from '/components/modal.js'; // Hält den AbortController des aktuellen FAB-Listeners - wird bei jedem render() erneuert. let _fabController = null; // ── Onboarding ────────────────────────────────────────────────────────────── const ONBOARDING_KEY = 'oikos-onboarded'; function getOnboardingSteps() { return [ { icon: 'home', title: t('onboarding.step1Title'), body: t('onboarding.step1Body') }, { icon: 'grid-2x2', title: t('onboarding.step2Title'), body: t('onboarding.step2Body') }, { icon: 'circle-check', title: t('onboarding.step3Title'), body: t('onboarding.step3Body') }, ]; } function showOnboarding(appContainer) { const steps = getOnboardingSteps(); let current = 0; const overlay = document.createElement('div'); overlay.className = 'onboarding-overlay'; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); function renderStep() { const step = steps[current]; const isLast = current === steps.length - 1; overlay.replaceChildren(); const card = document.createElement('div'); card.className = 'onboarding-card'; const icon = document.createElement('i'); icon.dataset.lucide = step.icon; icon.className = 'onboarding-icon'; icon.setAttribute('aria-hidden', 'true'); const title = document.createElement('h2'); title.className = 'onboarding-title'; title.textContent = step.title; const body = document.createElement('p'); body.className = 'onboarding-body'; body.textContent = step.body; const dots = document.createElement('div'); dots.className = 'onboarding-dots'; steps.forEach((_, i) => { const dot = document.createElement('span'); dot.className = `onboarding-dot${i === current ? ' onboarding-dot--active' : ''}`; dots.appendChild(dot); }); const actions = document.createElement('div'); actions.className = 'onboarding-actions'; const skipBtn = document.createElement('button'); skipBtn.className = 'btn btn--ghost'; skipBtn.textContent = t('onboarding.skip'); skipBtn.addEventListener('click', finish); const nextBtn = document.createElement('button'); nextBtn.className = 'btn btn--primary'; nextBtn.textContent = isLast ? t('onboarding.done') : t('onboarding.next'); nextBtn.addEventListener('click', () => { if (isLast) { finish(); return; } current++; renderStep(); if (window.lucide) window.lucide.createIcons({ el: overlay }); nextBtn.focus(); }); actions.appendChild(skipBtn); actions.appendChild(nextBtn); card.appendChild(icon); card.appendChild(title); card.appendChild(body); card.appendChild(dots); card.appendChild(actions); overlay.appendChild(card); if (window.lucide) window.lucide.createIcons({ el: overlay }); setTimeout(() => nextBtn.focus(), 50); } function finish() { localStorage.setItem(ONBOARDING_KEY, '1'); overlay.classList.add('onboarding-overlay--out'); overlay.addEventListener('animationend', () => overlay.remove(), { once: true }); // Fallback falls animationend nicht feuert (prefers-reduced-motion): setTimeout(() => overlay.remove(), 300); } renderStep(); appContainer.appendChild(overlay); } // -------------------------------------------------------- // Widget-Definitionen (Reihenfolge = Standard-Layout) // -------------------------------------------------------- const WIDGET_IDS = ['tasks', 'calendar', 'birthdays', 'budget', 'family', 'weather', 'shopping', 'meals', 'notes']; const DEFAULT_WIDGET_CONFIG = WIDGET_IDS.map((id) => ({ id, visible: true })); function widgetLabel(id) { const map = { tasks: () => t('nav.tasks'), calendar: () => t('nav.calendar'), shopping: () => t('nav.shopping'), meals: () => t('nav.meals'), notes: () => t('nav.notes'), weather: () => t('dashboard.weather'), birthdays: () => t('nav.birthdays'), budget: () => t('nav.budget'), family: () => t('dashboard.familyMembers'), }; return (map[id] ?? (() => id))(); } function widgetIcon(id) { const map = { tasks: 'check-square', calendar: 'calendar', birthdays: 'cake', budget: 'wallet', family: 'users', shopping: 'shopping-cart', meals: 'utensils', notes: 'pin', weather: 'cloud-sun' }; return map[id] ?? 'layout-dashboard'; } const BUDGET_CATEGORY_LABEL_KEYS = { housing: 'catHousing', food: 'catFood', transport: 'catTransport', personal_health: 'catPersonalHealth', leisure: 'catLeisure', shopping_clothing: 'catShoppingClothing', education: 'catEducation', financial_other: 'catFinancialOther', 'Erwerbseinkommen': 'catEarnedIncome', 'Kapitalerträge': 'catInvestmentIncome', 'Geschenke & Transfers': 'catTransferGiftIncome', 'Sozialleistungen': 'catGovernmentBenefits', 'Sonstiges Einkommen': 'catOtherIncome', }; // -------------------------------------------------------- // 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, timeStr) { if (!dateStr) return null; const dueDate = timeStr ? new Date(`${dateStr}T${timeStr}`) : new Date(`${dateStr}T23:59:59`); if (isNaN(dueDate)) return null; const now = new Date(); const diffMs = dueDate - now; const diffH = diffMs / (1000 * 60 * 60); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const dueDay = new Date(dueDate.getFullYear(), dueDate.getMonth(), dueDate.getDate()); const calDayDiff = Math.round((dueDay - today) / (1000 * 60 * 60 * 24)); const fullLabel = timeStr ? `${formatDate(dueDate)}, ${formatTime(dueDate)}` // beide aus i18n.js : formatDate(dueDate); if (diffMs < 0) { return { text: `${t('dashboard.overdue')} – ${fullLabel}`, overdue: true }; } if (calDayDiff === 1 && dueDate.getHours() >= 22 && diffH < 24) { return { text: `${t('dashboard.dueSoon')} – ${fullLabel}`, overdue: false, soon: true }; } if (calDayDiff === 0) { return { text: timeStr ? `${t('dashboard.dueToday')} – ${formatTime(dueDate)}` : t('dashboard.dueToday'), overdue: false, soon: true }; } if (calDayDiff === 1) { return { text: `${t('dashboard.dueTomorrow')} – ${formatTime(dueDate)}`, overdue: false }; } return { text: fullLabel, 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 budgetCategoryLabel(category) { const key = BUDGET_CATEGORY_LABEL_KEYS[category]; return key ? t(`budget.${key}`) : (category || '-'); } function formatCurrency(amount, currency = 'EUR') { return new Intl.NumberFormat(getLocale(), { style: 'currency', currency, maximumFractionDigits: Math.abs(amount) >= 1000 ? 0 : 2, }).format(amount || 0); } function widgetHeader(icon, title, count, linkHref, linkLabel) { linkLabel = linkLabel ?? t('dashboard.allLink'); const badge = count != null ? `${count}` : ''; return `
${title} ${badge}
`; } // -------------------------------------------------------- // Skeleton // -------------------------------------------------------- function skeletonWidget(lines = 3) { const lineHtml = Array.from({ length: lines }, (_, i) => `
`).join(''); return `
${lineHtml}
`; } // -------------------------------------------------------- // Widget-Renderer // -------------------------------------------------------- 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, t.due_time); 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(fmtLocation(e.location))}` : ''} ${e.cal_name ? `${esc(e.cal_name)}` : ''}
`; }).join(''); return `
${widgetHeader('calendar', t('nav.calendar'), events.length, '/calendar')}
${items}
`; } function renderUpcomingBirthdays(birthdays) { if (!birthdays.length) { return `
${widgetHeader('cake', t('nav.birthdays'), 0, '/birthdays')}
${t('dashboard.noBirthdays')}
`; } const items = birthdays.map((b) => { const daysLabel = b.days_until === 0 ? t('common.today') : b.days_until === 1 ? t('common.tomorrow') : t('dashboard.daysLeft', { count: b.days_until }); return `
${b.photo_data ? `` : `${esc(initials(b.name))}`}
${esc(b.name)}
${formatDate(b.next_birthday)} · ${daysLabel}
${esc(String(b.next_age ?? ''))}
`; }).join(''); return `
${widgetHeader('cake', t('nav.birthdays'), birthdays.length, '/birthdays')}
${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}
`; } function renderFamilyWidget(users) { const visible = users.slice(0, 6); const avatars = visible.map((u) => ` ${esc(initials(u.display_name))} `).join(''); return `
${widgetHeader('users', t('dashboard.familyMembers'), users.length, '/settings')}
${users.length}
${t('dashboard.participantsAdded')}
${avatars}
`; } function renderBudgetWidget(budget, currency) { const income = budget?.income || 0; const expenses = budget?.expenses || 0; const balance = budget?.balance || 0; const savingsRate = income > 0 ? Math.round((balance / income) * 100) : 0; const balanceTone = balance >= 0 ? 'positive' : 'negative'; const hasData = (budget?.entryCount || 0) > 0; return `
${widgetHeader('wallet', t('dashboard.budgetOverview'), null, '/budget')}
${t('dashboard.monthlyBalance')} ${formatCurrency(balance, currency)}
${t('dashboard.monthlyIncome')} ${formatCurrency(income, currency)}
${t('dashboard.monthlyExpenses')} ${formatCurrency(expenses, currency)}
${t('dashboard.savingsRate')} ${income > 0 ? `${savingsRate}%` : '-'}
${t('dashboard.budgetEntries')} ${budget?.entryCount || 0}
`; } function renderQuickAction({ route, label, icon, tone = '' }) { return ` `; } function renderKpiTile({ title, value, meta, icon, route, tone = '' }) { return ` `; } function renderDashboardOverview(user, stats = null, weather = null) { const dateLabel = formatDate(new Date()); const weatherLabel = weather ? `${esc(weather.city)} · ${esc(weather.current?.temp)}${weather.units === 'imperial' ? '°F' : weather.units === 'standard' ? 'K' : '°C'}` : t('dashboard.weather'); const actions = [ { route: '/tasks', label: t('nav.tasks'), icon: 'check-square', tone: 'blue' }, { route: '/calendar', label: t('nav.calendar'), icon: 'calendar', tone: 'violet' }, { route: '/shopping', label: t('nav.shopping'), icon: 'shopping-cart', tone: 'green' }, { route: '/notes', label: t('nav.notes'), icon: 'sticky-note', tone: 'amber' }, ].map(renderQuickAction).join(''); const kpis = stats ? [ renderKpiTile({ title: t('tasks.title'), value: String(stats.overdueCount ?? 0), meta: t('dashboard.overdue'), icon: 'alert-circle', route: '/tasks', tone: 'danger', }), renderKpiTile({ title: t('nav.calendar'), value: String(stats.todayEventCount ?? 0), meta: t('common.today'), icon: 'calendar-days', route: '/calendar', tone: 'calendar', }), renderKpiTile({ title: t('nav.meals'), value: stats.todayMealTitle ? esc(stats.todayMealTitle) : '-', meta: t('dashboard.todayMeals'), icon: 'utensils', route: '/meals', tone: 'meals', }), renderKpiTile({ title: t('dashboard.weather'), value: weatherLabel, meta: t('dashboard.weatherRefreshTitle'), icon: 'cloud-sun', route: '/', tone: 'weather', }), renderKpiTile({ title: t('nav.birthdays'), value: String(stats.birthdayCount ?? 0), meta: t('dashboard.upcomingBirthdays'), icon: 'cake', route: '/birthdays', tone: 'birthdays', }), renderKpiTile({ title: t('dashboard.familyMembers'), value: String(stats.familyCount ?? 0), meta: t('dashboard.participantsAdded'), icon: 'users', route: '/settings', tone: 'family', }), ].join('') : `
`; return `
${dateLabel}

${greeting(user.display_name)}

${actions}
${kpis}
`; } function widgetRegion(id) { return ['budget', 'family', 'weather', 'shopping', 'meals'].includes(id) ? 'side' : 'main'; } function widgetTileClass(id) { const map = { tasks: 'dashboard-tile--wide', calendar: 'dashboard-tile--compact', birthdays: 'dashboard-tile--compact', budget: 'dashboard-tile--wide', family: 'dashboard-tile--compact', meals: 'dashboard-tile--compact', notes: 'dashboard-tile--wide', shopping: 'dashboard-tile--compact', weather: 'dashboard-tile--wide', }; return map[id] || 'dashboard-tile--compact'; } function renderDashboardTile(id, html) { if (!html) return ''; return `
${html}
`; } function renderDashboardLayout(cfg, data, weather, currency) { const widgetById = { tasks: () => renderUrgentTasks(data.urgentTasks ?? []), calendar: () => renderUpcomingEvents(data.upcomingEvents ?? []), birthdays: () => renderUpcomingBirthdays(data.birthdays ?? []), budget: () => renderBudgetWidget(data.budget ?? {}, currency), family: () => renderFamilyWidget(data.users ?? []), meals: () => renderTodayMeals(data.todayMeals ?? []), notes: () => renderPinnedNotes(data.pinnedNotes ?? []), shopping: () => renderShoppingLists(data.shoppingLists ?? []), weather: () => (weather ? renderWeatherWidget(weather) : ''), }; const visible = cfg.filter((w) => w.visible && widgetById[w.id]); const mainTiles = visible .filter((w) => widgetRegion(w.id) === 'main') .map((w) => renderDashboardTile(w.id, widgetById[w.id]())) .join(''); const sideTiles = visible .filter((w) => widgetRegion(w.id) === 'side') .map((w) => renderDashboardTile(w.id, widgetById[w.id]())) .join(''); return `
${mainTiles}
`; } function renderDashboardSkeleton() { return `
${skeletonWidget(3)} ${skeletonWidget(3)} ${skeletonWidget(2)} ${skeletonWidget(3)}
`; } // -------------------------------------------------------- // 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, units } = weather; const unitSymbol = units === 'imperial' ? '°F' : units === 'standard' ? 'K' : '°C'; const windUnit = units === 'imperial' ? 'mph' : 'km/h'; 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)}${unitSymbol}
${esc(current.desc)}
${esc(city)}
${t('dashboard.weatherFeelsLike', { temp: current.feels_like, humidity: current.humidity, wind: current.wind_speed, windUnit })}
${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'); const fabBackdrop = container.querySelector('#fab-backdrop'); if (!fabMain) return; // "Neu"-Button-Selector auf der jeweiligen Zielseite const FAB_NEW_BTN = { '/tasks': '#btn-new-task', '/calendar': '#fab-new-event', '/shopping': '#fab-new-item', '/notes': '#fab-new-note', }; 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)); fabBackdrop?.classList.toggle('fab-backdrop--visible', 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 = async () => { toggleFab(false); await window.oikos.navigate(el.dataset.route); const btnSelector = FAB_NEW_BTN[el.dataset.route]; if (btnSelector) document.querySelector(btnSelector)?.click(); }; 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 }); } // -------------------------------------------------------- // Customize-Modal // -------------------------------------------------------- function openCustomizeModal(currentConfig, onSave) { let draft = currentConfig.map((w) => ({ ...w })); function buildRows() { return draft.map((w, i) => { const isFirst = i === 0; const isLast = i === draft.length - 1; return `
${widgetLabel(w.id)}
`; }).join(''); } openModal({ title: t('dashboard.customizeTitle'), size: 'sm', content: `
${buildRows()}
`, onSave(panel) { if (window.lucide) window.lucide.createIcons({ el: panel }); function rebuildList() { const list = panel.querySelector('#customize-list'); if (!list) return; const doRebuild = () => { list.replaceChildren(); list.insertAdjacentHTML('beforeend', buildRows()); if (window.lucide) window.lucide.createIcons({ el: list }); wireRows(); }; const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (document.startViewTransition && !reducedMotion) { document.startViewTransition(doRebuild); } else { doRebuild(); } } function wireRows() { const list = panel.querySelector('#customize-list'); if (!list) return; list.querySelectorAll('.customize-row__check').forEach((cb) => { cb.addEventListener('change', () => { const id = cb.dataset.id; const entry = draft.find((w) => w.id === id); if (entry) entry.visible = cb.checked; }); }); list.querySelectorAll('[data-move]').forEach((btn) => { btn.addEventListener('click', () => { const id = btn.dataset.id; const dir = btn.dataset.move; const idx = draft.findIndex((w) => w.id === id); if (dir === 'up' && idx > 0) { [draft[idx - 1], draft[idx]] = [draft[idx], draft[idx - 1]]; } else if (dir === 'down' && idx < draft.length - 1) { [draft[idx], draft[idx + 1]] = [draft[idx + 1], draft[idx]]; } rebuildList(); }); }); } wireRows(); panel.querySelector('#customize-reset')?.addEventListener('click', () => { draft = DEFAULT_WIDGET_CONFIG.map((w) => ({ ...w })); rebuildList(); }); panel.querySelector('#customize-save')?.addEventListener('click', async () => { const saveBtn = panel.querySelector('#customize-save'); saveBtn.disabled = true; try { await api.put('/preferences', { dashboard_widgets: draft }); closeModal({ force: true }); onSave(draft); window.oikos?.showToast(t('dashboard.customizeSaved'), 'success', 1500); } catch { window.oikos?.showToast(t('common.errorGeneric'), 'error'); } finally { saveBtn.disabled = false; } }); }, }); } // -------------------------------------------------------- // Task Quick-Action Modal // -------------------------------------------------------- function openTaskQuickAction(taskId, taskTitle, rerender) { openModal({ title: taskTitle, size: 'sm', content: ` `, onSave: (panel) => { panel.querySelector('[data-action="done"]').addEventListener('click', async () => { try { await api.patch(`/tasks/${taskId}/status`, { status: 'done' }); closeModal({ force: true }); window.oikos?.showToast(t('tasks.swipedDoneToast'), 'success'); rerender(); } catch (err) { window.oikos?.showToast(err.message, 'danger'); } }); panel.querySelector('[data-action="edit"]').addEventListener('click', () => { closeModal({ force: true }); window.oikos.navigate(`/tasks?open=${taskId}`); }); }, }); } // -------------------------------------------------------- // Navigations-Links verdrahten // -------------------------------------------------------- function wireLinks(container, rerender) { 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(); } }); } }); // Task-Items öffnen Quick-Action-Modal statt direkt zu navigieren container.querySelectorAll('.task-item[data-task-id]').forEach((el) => { const show = () => openTaskQuickAction(el.dataset.taskId, el.dataset.taskTitle, rerender); el.addEventListener('click', show); el.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); show(); } }); }); } // -------------------------------------------------------- // Haupt-Render // -------------------------------------------------------- export async function render(container, { user }) { _fabController?.abort(); _fabController = new AbortController(); container.innerHTML = `

${t('dashboard.title')}

${renderDashboardSkeleton()}
${renderFab()} `; let data = { upcomingEvents: [], urgentTasks: [], todayMeals: [], pinnedNotes: [], shoppingLists: [], birthdays: [], users: [], budget: {} }; let weather = null; let widgetConfig = DEFAULT_WIDGET_CONFIG; let currency = 'EUR'; try { const [dashRes, weatherRes, prefsRes] = await Promise.all([ api.get('/dashboard'), api.get('/weather').catch(() => ({ data: null })), api.get('/preferences').catch(() => ({ data: {} })), ]); data = dashRes; weather = weatherRes.data ?? null; widgetConfig = prefsRes.data?.dashboard_widgets ?? DEFAULT_WIDGET_CONFIG; currency = prefsRes.data?.currency ?? 'EUR'; } catch (err) { console.error('[Dashboard] Ladefehler:', err.message, 'Status:', err.status ?? 'network'); window.oikos?.showToast(t('dashboard.loadError'), 'warning'); } const today = new Date().toDateString(); const stats = { overdueCount: (data.urgentTasks ?? []).filter((t) => { const due = formatDueDate(t.due_date, t.due_time); return due?.overdue === true; }).length, dueSoonCount: (data.urgentTasks ?? []).filter((t) => { const due = formatDueDate(t.due_date, t.due_time); return due?.soon === true; }).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, birthdayCount: data.birthdayCount ?? (data.birthdays ?? []).length, familyCount: (data.users ?? []).length, }; const rerender = () => render(container, { user }); function rebuildDashboard(cfg) { const shell = container.querySelector('#dashboard-shell'); if (!shell) return; shell.replaceChildren(); shell.insertAdjacentHTML('beforeend', ` ${renderDashboardOverview(user, stats, weather)} ${renderDashboardLayout(cfg, data, weather, currency)} `); wireLinks(container, rerender); if (window.lucide) window.lucide.createIcons(); wireWeatherRefresh(container, (updatedWeather) => { weather = updatedWeather; rebuildDashboard(cfg); }); container.querySelector('#dashboard-customize-btn')?.addEventListener('click', () => { openCustomizeModal(widgetConfig, (newConfig) => { widgetConfig = newConfig; rebuildDashboard(widgetConfig); }); }, { signal: _fabController.signal }); } rebuildDashboard(widgetConfig); initFab(container, _fabController.signal); // 30-Minuten Auto-Refresh für Wetter const refreshBtn = container.querySelector('#weather-refresh-btn'); if (refreshBtn) { const doAutoRefresh = async () => { try { const res = await api.get('/weather').catch(() => ({ data: null })); weather = res.data ?? null; rebuildDashboard(widgetConfig); } catch { /* silently ignore */ } }; const timerId = setInterval(doAutoRefresh, 30 * 60 * 1000); _fabController.signal.addEventListener('abort', () => clearInterval(timerId)); } if (!localStorage.getItem(ONBOARDING_KEY)) { setTimeout(() => showOnboarding(container), 400); } } function wireWeatherRefresh(container, onUpdated = null) { 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 }); onUpdated?.(res.data ?? null); window.oikos?.showToast(t('dashboard.weatherUpdated'), 'success', 1500); } } catch { /* silently ignore */ } }; refreshBtn.addEventListener('click', doWeatherRefresh, { signal: _fabController.signal }); }