/**
* 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 `
`;
}
// --------------------------------------------------------
// Skeleton
// --------------------------------------------------------
function skeletonWidget(lines = 3) {
const lineHtml = Array.from({ length: lines }, (_, i) => `
`).join('');
return `
`;
}
// --------------------------------------------------------
// Widget-Renderer
// --------------------------------------------------------
function renderUrgentTasks(tasks) {
if (!tasks.length) {
return ``;
}
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 ``;
}
function renderUpcomingEvents(events) {
if (!events.length) {
return ``;
}
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 ``;
}
function renderUpcomingBirthdays(birthdays) {
if (!birthdays.length) {
return ``;
}
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 `
`;
}).join('');
return ``;
}
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 ``;
}
function renderPinnedNotes(notes) {
if (!notes.length) {
return ``;
}
const items = notes.map((n) => `
${n.title ? `
${esc(n.title)}
` : ''}
${esc(n.content)}
`).join('');
return ``;
}
function renderFamilyWidget(users) {
const visible = users.slice(0, 6);
const avatars = visible.map((u) => `
${esc(initials(u.display_name))}
`).join('');
return ``;
}
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 ``;
}
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 `
`;
}
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 ``;
}
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 `
`;
}
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 `
`;
}).join('');
return ``;
}
// --------------------------------------------------------
// 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 `
`;
}).join('');
return `
`;
}
// --------------------------------------------------------
// 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 `
`;
}).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 });
}