Files
oikos/public/pages/dashboard.js
T
Ulas Kalayci 7b04d5a48a fix: resolve design token violations and locale bug
- Add --color-warning-translucent and --color-soon tokens to tokens.css
- Replace hardcoded font-size 1.4rem with var(--text-xl) in dashboard.css
- Replace hardcoded rgba color with var(--color-warning-translucent)
- Remove duplicate .task-item__meta--overdue rule
- Fix hardcoded 'de-DE' locale: use formatTime() from i18n.js
- Fix formatDueDate: don't show time (23:59) when no due_time is set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 23:38:56 +02:00

837 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
// --------------------------------------------------------
// Widget-Definitionen (Reihenfolge = Standard-Layout)
// --------------------------------------------------------
const WIDGET_IDS = ['weather', 'tasks', 'calendar', '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'),
};
return (map[id] ?? (() => id))();
}
function widgetIcon(id) {
const map = { tasks: 'check-square', calendar: 'calendar', shopping: 'shopping-cart', meals: 'utensils', notes: 'pin', weather: 'cloud-sun' };
return map[id] ?? 'layout-dashboard';
}
// --------------------------------------------------------
// 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 widgetHeader(icon, title, count, linkHref, linkLabel) {
linkLabel = linkLabel ?? t('dashboard.allLink');
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>
<button type="button" data-route="${linkHref}" class="widget__link">
${linkLabel}
</button>
</div>
`;
}
// --------------------------------------------------------
// Skeleton
// --------------------------------------------------------
function skeletonWidget(lines = 3) {
const lineHtml = Array.from({ length: lines }, (_, i) => `
<div class="skeleton skeleton-line ${i % 2 === 0 ? 'skeleton-line--full' : 'skeleton-line--medium'}"></div>
`).join('');
return `
<div class="widget-skeleton">
<div class="skeleton skeleton-line skeleton-line--short"></div>
${lineHtml}
</div>
`;
}
// --------------------------------------------------------
// Widget-Renderer
// --------------------------------------------------------
function renderGreeting(user, stats = {}) {
const { overdueCount = 0, dueSoonCount = 0, todayEventCount = 0, todayMealTitle = null } = stats;
const statChips = [];
if (overdueCount > 0)
statChips.push(`<span class="greeting-chip greeting-chip--warn">
<i data-lucide="alert-circle" class="icon-sm" style="flex-shrink:0" aria-hidden="true"></i>
${overdueCount > 1 ? t('dashboard.overdueTasksChipPlural', { count: overdueCount }) : t('dashboard.overdueTasksChip', { count: overdueCount })}
</span>`);
if (dueSoonCount > 0)
statChips.push(`<span class="greeting-chip greeting-chip--due">
<i data-lucide="clock" class="icon-sm" style="flex-shrink:0" aria-hidden="true"></i>
${dueSoonCount > 1 ? t('dashboard.urgentTasksChipPlural', { count: dueSoonCount }) : t('dashboard.urgentTasksChip', { count: dueSoonCount })}
</span>`);
if (todayEventCount > 0)
statChips.push(`<span class="greeting-chip">
<i data-lucide="calendar" class="icon-sm" style="flex-shrink:0" aria-hidden="true"></i>
${todayEventCount > 1 ? t('dashboard.eventsChipPlural', { count: todayEventCount }) : t('dashboard.eventsChip', { count: todayEventCount })}
</span>`);
if (todayMealTitle)
statChips.push(`<span class="greeting-chip">
<i data-lucide="utensils" class="icon-sm" style="flex-shrink:0" aria-hidden="true"></i>
${t('dashboard.todayMealChip', { title: esc(todayMealTitle) })}
</span>`);
const time = formatTime(new Date());
return `
<div class="widget-greeting">
<div class="widget-greeting__inner">
<div class="widget-greeting__content">
<div class="widget-greeting__title">${formatDate(new Date())} - ${time}</div>
${statChips.length ? `<div class="widget-greeting__chips">${statChips.join('')}</div>` : ''}
</div>
<button class="widget-customize-btn" id="dashboard-customize-btn"
aria-label="${t('dashboard.customize')}" title="${t('dashboard.customize')}">
<i data-lucide="settings-2" class="icon-base" aria-hidden="true"></i>
</button>
</div>
</div>
`;
}
function renderUrgentTasks(tasks) {
if (!tasks.length) {
return `<div class="widget">
${widgetHeader('check-square', t('nav.tasks'), 0, '/tasks')}
<div class="widget__empty">
<i data-lucide="check-circle" class="empty-state__icon" style="color:var(--color-success)" aria-hidden="true"></i>
<div>${t('dashboard.allDone')}</div>
</div>
</div>`;
}
const items = tasks.map((t) => {
const due = formatDueDate(t.due_date, t.due_time);
return `
<div class="task-item" data-task-id="${t.id}" data-task-title="${esc(t.title)}" role="button" tabindex="0">
${t.priority !== 'none' ? `<div class="task-item__priority task-item__priority--${t.priority}" aria-hidden="true"></div>` : ''}
<span class="sr-only">${PRIORITY_LABELS()[t.priority] ?? t.priority}</span>
<div class="task-item__content">
<div class="task-item__title">${esc(t.title)}</div>
${due ? `<div class="task-item__meta ${due.overdue ? 'task-item__meta--overdue' : ''} ${due.soon ? 'task-item__meta--soon' : ''}">${due.text}</div>` : ''}
</div>
${t.assigned_color ? `
<div class="task-item__avatar" style="background-color:${esc(t.assigned_color)}"
title="${esc(t.assigned_name)}">${esc(initials(t.assigned_name || ''))}</div>` : ''}
</div>
`;
}).join('');
return `<div class="widget">
${widgetHeader('check-square', t('nav.tasks'), tasks.length, '/tasks')}
<div class="widget__body">${items}</div>
</div>`;
}
function renderUpcomingEvents(events) {
if (!events.length) {
return `<div class="widget">
${widgetHeader('calendar', t('nav.calendar'), 0, '/calendar')}
<div class="widget__empty">
<i data-lucide="calendar-check" class="empty-state__icon" aria-hidden="true"></i>
<div>${t('dashboard.noEvents')}</div>
</div>
</div>`;
}
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 `
<div class="event-item" data-route="/calendar" role="button" tabindex="0">
<div class="event-item__bar" style="background-color:${esc(e.cal_color || e.color) || 'var(--color-accent)'}"></div>
<div class="event-item__content">
<div class="event-item__title">${esc(e.title)}</div>
<div class="event-item__time">
<span class="event-time-badge ${isToday ? 'event-time-badge--today' : ''}">${isToday ? t('common.today') : formatDateTime(e.start_datetime).split(',')[0]}</span>
${timeStr}
${e.location ? ` · ${esc(fmtLocation(e.location))}` : ''}
${e.cal_name ? `<span class="event-item__cal">${esc(e.cal_name)}</span>` : ''}
</div>
</div>
</div>
`;
}).join('');
return `<div class="widget">
${widgetHeader('calendar', t('nav.calendar'), events.length, '/calendar')}
<div class="widget__body">${items}</div>
</div>`;
}
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 `
<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">${mealLabels[type]}</div>
<div class="meal-slot__title">${meal ? esc(meal.title) : '-'}</div>
</div>
`;
}).join('');
return `<div class="widget widget--meals">
${widgetHeader('utensils', t('dashboard.todayMeals'), null, '/meals', t('dashboard.weekLink'))}
<div class="meal-slots">${slots}</div>
</div>`;
}
function renderPinnedNotes(notes) {
if (!notes.length) {
return `<div class="widget">
${widgetHeader('pin', t('nav.notes'), 0, '/notes')}
<div class="widget__empty">
<i data-lucide="sticky-note" class="empty-state__icon" aria-hidden="true"></i>
<div>${t('dashboard.noPinnedNotes')}</div>
</div>
</div>`;
}
const items = notes.map((n) => `
<div class="note-item" data-route="/notes" role="button" tabindex="0"
style="--note-color:${esc(n.color)};">
${n.title ? `<div class="note-item__title">${esc(n.title)}</div>` : ''}
<div class="note-item__content">${esc(n.content)}</div>
</div>
`).join('');
return `<div class="widget widget--wide">
${widgetHeader('pin', t('nav.notes'), notes.length, '/notes')}
<div class="notes-grid-widget">${items}</div>
</div>`;
}
// --------------------------------------------------------
// 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) => `
<div class="shopping-widget-item">
<span class="shopping-widget-item__dot"></span>
<span class="shopping-widget-item__name">${esc(item.name)}</span>
${item.quantity ? `<span class="shopping-widget-item__qty">${esc(item.quantity)}</span>` : ''}
</div>
`).join('');
const moreCount = list.open_count - list.items.length;
return `
<div class="shopping-widget-list" data-route="/shopping" role="button" tabindex="0">
<div class="shopping-widget-list__header">
<span class="shopping-widget-list__name">${esc(list.name)}</span>
<span class="shopping-widget-list__count">${list.total_count - list.open_count}/${list.total_count}</span>
</div>
<div class="shopping-widget-list__progress">
<div class="shopping-widget-list__bar" style="width:${progress}%"></div>
</div>
<div class="shopping-widget-list__items">
${itemsHtml}
${moreCount > 0 ? `<div class="shopping-widget-item shopping-widget-item--more">${t('dashboard.shoppingMore', { count: moreCount })}</div>` : ''}
</div>
</div>
`;
}).join('');
return `<div class="widget">
${widgetHeader('shopping-cart', t('nav.shopping'), totalOpen, '/shopping')}
<div class="widget__body">${listsHtml}</div>
</div>`;
}
// --------------------------------------------------------
// 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 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 `
<div class="weather-forecast__day${extraCls}">
<div class="weather-forecast__label">${label}</div>
<img class="weather-forecast__icon" src="${WEATHER_ICON_BASE}${d.icon}"
alt="${esc(d.desc)}" width="32" height="32" loading="lazy">
<div class="weather-forecast__temps">
<span class="weather-forecast__high">${d.temp_max}°</span>
<span class="weather-forecast__low">${d.temp_min}°</span>
</div>
</div>`;
}).join('');
return `
<div class="widget weather-widget" id="weather-widget">
<button class="weather-widget__refresh" id="weather-refresh-btn" aria-label="${t('dashboard.weatherRefresh')}" title="${t('dashboard.weatherRefreshTitle')}">
<i data-lucide="refresh-cw" class="icon-md" aria-hidden="true"></i>
</button>
<div class="weather-widget__inner">
<div class="weather-widget__main">
<div class="weather-widget__left">
<div class="weather-widget__temp">${esc(current.temp)}${unitSymbol}</div>
<div class="weather-widget__desc">${esc(current.desc)}</div>
<div class="weather-widget__city">${esc(city)}</div>
<div class="weather-widget__meta">
${t('dashboard.weatherFeelsLike', { temp: current.feels_like, humidity: current.humidity, wind: current.wind_speed })}
</div>
</div>
<img class="weather-widget__icon" src="${WEATHER_ICON_BASE}${current.icon}"
alt="${esc(current.desc)}" width="80" height="80" loading="lazy">
</div>
${forecast.length ? `<div class="weather-forecast">${forecastHtml}</div>` : ''}
</div>
</div>`;
}
// --------------------------------------------------------
// 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) => `
<div class="fab-action" data-route="${a.route}" role="button" tabindex="-1"
aria-label="${a.label}">
<span class="fab-action__label">${a.label}</span>
<button class="fab-action__btn" tabindex="-1" aria-hidden="true">
<i data-lucide="${a.icon}" aria-hidden="true"></i>
</button>
</div>
`).join('');
return `
<div class="fab-backdrop" id="fab-backdrop"></div>
<div class="fab-container" id="fab-container">
<button class="fab-main" id="fab-main" aria-label="${t('nav.quickActions')}" aria-expanded="false">
<i data-lucide="plus" aria-hidden="true"></i>
</button>
<div class="fab-actions" id="fab-actions" aria-hidden="true">
${actionsHtml}
</div>
</div>
`;
}
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 });
}
// --------------------------------------------------------
// Widget-Rendering nach Konfiguration
// --------------------------------------------------------
function renderWidgets(cfg, data, weather) {
const renderers = {
tasks: () => renderUrgentTasks(data.urgentTasks ?? []),
calendar: () => renderUpcomingEvents(data.upcomingEvents ?? []),
shopping: () => renderShoppingLists(data.shoppingLists ?? []),
meals: () => renderTodayMeals(data.todayMeals ?? []),
notes: () => renderPinnedNotes(data.pinnedNotes ?? []),
weather: () => (weather ? renderWeatherWidget(weather) : ''),
};
return cfg
.filter((w) => w.visible)
.map((w) => (renderers[w.id] ? renderers[w.id]() : ''))
.join('');
}
// --------------------------------------------------------
// 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 `
<div class="customize-row" data-id="${w.id}">
<label class="customize-row__toggle">
<input type="checkbox" class="customize-row__check" data-id="${w.id}"
${w.visible ? 'checked' : ''} aria-label="${widgetLabel(w.id)}">
<span class="customize-row__slider" aria-hidden="true"></span>
</label>
<i data-lucide="${widgetIcon(w.id)}" class="customize-row__icon" aria-hidden="true"></i>
<span class="customize-row__name">${widgetLabel(w.id)}</span>
<div class="customize-row__actions">
<button class="customize-row__btn" data-move="up" data-id="${w.id}"
${isFirst ? 'disabled' : ''} aria-label="${t('dashboard.customizeMoveUp')}">
<i data-lucide="chevron-up" class="icon-md" aria-hidden="true"></i>
</button>
<button class="customize-row__btn" data-move="down" data-id="${w.id}"
${isLast ? 'disabled' : ''} aria-label="${t('dashboard.customizeMoveDown')}">
<i data-lucide="chevron-down" class="icon-md" aria-hidden="true"></i>
</button>
</div>
</div>
`;
}).join('');
}
openModal({
title: t('dashboard.customizeTitle'),
size: 'sm',
content: `
<div class="customize-list" id="customize-list">${buildRows()}</div>
<div class="modal-actions" style="margin-top:var(--space-4)">
<button type="button" class="btn btn--ghost" id="customize-reset">${t('dashboard.customizeReset')}</button>
<button type="button" class="btn btn--primary" id="customize-save">${t('common.save')}</button>
</div>`,
onSave(panel) {
if (window.lucide) window.lucide.createIcons({ el: panel });
function rebuildList() {
const list = panel.querySelector('#customize-list');
if (!list) return;
list.replaceChildren();
list.insertAdjacentHTML('beforeend', buildRows());
if (window.lucide) window.lucide.createIcons({ el: list });
wireRows();
}
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();
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: `
<div class="modal-actions">
<button type="button" class="btn btn--ghost" data-action="edit">
<i data-lucide="edit-2" style="width:16px;height:16px;" aria-hidden="true"></i>
${t('common.edit')}
</button>
<button type="button" class="btn btn--primary" data-action="done">
<i data-lucide="check-circle" style="width:16px;height:16px;" aria-hidden="true"></i>
${t('tasks.kanbanMoveToDone')}
</button>
</div>
`,
onSave: (panel) => {
panel.querySelector('[data-action="done"]').addEventListener('click', async () => {
try {
await api.patch(`/tasks/${taskId}/status`, { status: 'done' });
closeModal();
window.oikos?.showToast(t('tasks.swipedDoneToast'), 'success');
rerender();
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
});
panel.querySelector('[data-action="edit"]').addEventListener('click', () => {
closeModal();
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 = `
<div class="dashboard">
<h1 class="sr-only">${t('dashboard.title')}</h1>
<div class="dashboard__grid">
${renderGreeting(user, {})}
${skeletonWidget(3)}
${skeletonWidget(3)}
${skeletonWidget(2)}
${skeletonWidget(3)}
</div>
</div>
${renderFab()}
`;
let data = { upcomingEvents: [], urgentTasks: [], todayMeals: [], pinnedNotes: [], shoppingLists: [] };
let weather = null;
let widgetConfig = DEFAULT_WIDGET_CONFIG;
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;
} 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,
};
const rerender = () => render(container, { user });
function rebuildGrid(cfg) {
const grid = container.querySelector('.dashboard__grid');
if (!grid) return;
const greeting = grid.querySelector('.widget-greeting');
grid.replaceChildren(...(greeting ? [greeting] : []));
grid.insertAdjacentHTML('beforeend', renderWidgets(cfg, data, weather));
wireLinks(container, rerender);
if (window.lucide) window.lucide.createIcons();
wireWeatherRefresh(container);
}
// Greeting in-place aktualisieren (Stats-Chips hinzufügen), kein Gesamt-Reset
const greetingEl = container.querySelector('.widget-greeting');
if (greetingEl) greetingEl.outerHTML = renderGreeting(user, stats);
// Skeletons durch echte Widgets ersetzen
rebuildGrid(widgetConfig);
initFab(container, _fabController.signal);
container.querySelector('#dashboard-customize-btn')?.addEventListener(
'click',
() => openCustomizeModal(widgetConfig, (newConfig) => {
widgetConfig = newConfig;
rebuildGrid(widgetConfig);
}),
{ signal: _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 }));
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 */ }
};
const timerId = setInterval(doAutoRefresh, 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);
window.oikos?.showToast(t('dashboard.weatherUpdated'), 'success', 1500);
}
} catch { /* silently ignore */ }
};
refreshBtn.addEventListener('click', doWeatherRefresh, { signal: _fabController.signal });
}