/**
* 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 `
`;
}
// --------------------------------------------------------
// Skeleton
// --------------------------------------------------------
function skeletonWidget(lines = 3) {
const lineHtml = Array.from({ length: lines }, (_, i) => `
`).join('');
return `
`;
}
// --------------------------------------------------------
// 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 `
`;
}
function renderUrgentTasks(tasks) {
if (!tasks.length) {
return ``;
}
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 ``;
}
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(e.location)}` : ''}
`;
}).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 ``;
}
// --------------------------------------------------------
// 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 } = 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 `
`;
}).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');
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 = `
${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 });
}