From 8f96e066f3c8a8f8678d95432f83776bff550c5f Mon Sep 17 00:00:00 2001 From: Ulas Date: Tue, 14 Apr 2026 08:04:26 +0200 Subject: [PATCH] feat: customizable dashboard layout (#32) Users can now show/hide widgets and reorder them via a settings button in the greeting header. Configuration is persisted server-side in sync_config (dashboard_widgets key) and shared across all family members. - Greeting widget gets a settings icon button opening a customize modal - Modal lists all widgets (tasks, calendar, shopping, meals, notes, weather) with toggle switches and up/down reorder buttons - Reset to default layout available in the modal - GET /preferences now returns dashboard_widgets; PUT accepts it - All 10 locales updated with new i18n keys --- CHANGELOG.md | 10 ++ package.json | 2 +- public/locales/de.json | 9 +- public/locales/el.json | 11 +- public/locales/en.json | 9 +- public/locales/es.json | 11 +- public/locales/fr.json | 11 +- public/locales/it.json | 11 +- public/locales/ru.json | 11 +- public/locales/sv.json | 11 +- public/locales/tr.json | 11 +- public/locales/zh.json | 11 +- public/pages/dashboard.js | 233 ++++++++++++++++++++++++++++++----- public/styles/dashboard.css | 149 ++++++++++++++++++++++ server/routes/preferences.js | 45 ++++++- 15 files changed, 495 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73dc95c..59b726b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.18.1] - 2026-04-14 + +### Added +- Customizable dashboard layout: users can now show/hide individual widgets and reorder them via a settings button in the greeting header +- New "Anpassen" button (settings icon) in the dashboard greeting widget opens a modal with toggle switches and up/down controls for each widget (Tasks, Calendar, Shopping, Meals, Notes, Weather) +- Widget configuration persisted server-side via `dashboard_widgets` preference key in `sync_config` table - survives page reload and applies across all family members +- Reset to default layout button in the customize modal +- New i18n keys for all 10 supported locales: `dashboard.customize`, `dashboard.customizeTitle`, `dashboard.customizeReset`, `dashboard.customizeSaved`, `dashboard.weather`, `dashboard.customizeMoveUp`, `dashboard.customizeMoveDown` +- Backend: `GET /api/v1/preferences` now includes `dashboard_widgets` in the response; `PUT /api/v1/preferences` accepts `dashboard_widgets` array with validation and normalization + ## [0.18.0] - 2026-04-14 ### Added diff --git a/package.json b/package.json index b1be33e..772278d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.18.0", + "version": "0.18.1", "description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.", "main": "server/index.js", "type": "module", diff --git a/public/locales/de.json b/public/locales/de.json index 4a36372..63a7d0f 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -77,7 +77,14 @@ "dueSoon": "Heute fällig", "dueTomorrow": "Morgen fällig", "allDay": "Ganztägig", - "shoppingMore": "+{{count}} weitere" + "shoppingMore": "+{{count}} weitere", + "weather": "Wetter", + "customize": "Anpassen", + "customizeTitle": "Widgets anpassen", + "customizeReset": "Standard", + "customizeSaved": "Dashboard gespeichert", + "customizeMoveUp": "Nach oben", + "customizeMoveDown": "Nach unten" }, "tasks": { "title": "Aufgaben", diff --git a/public/locales/el.json b/public/locales/el.json index 91512d1..87b0eda 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -77,7 +77,14 @@ "dueSoon": "Λήγει σήμερα", "dueTomorrow": "Λήγει αύριο", "allDay": "Όλη μέρα", - "shoppingMore": "+{{count}} ακόμα" + "shoppingMore": "+{{count}} ακόμα", + "weather": "Καιρός", + "customize": "Προσαρμογή", + "customizeTitle": "Προσαρμογή widgets", + "customizeReset": "Επαναφορά", + "customizeSaved": "Πίνακας αποθηκεύτηκε", + "customizeMoveUp": "Πάνω", + "customizeMoveDown": "Κάτω" }, "tasks": { "title": "Εργασίες", @@ -589,4 +596,4 @@ "unitMonth": "μήνα", "unitMonths": "μήνες" } -} \ No newline at end of file +} diff --git a/public/locales/en.json b/public/locales/en.json index 92679e6..3df0b55 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -77,7 +77,14 @@ "dueSoon": "Due today", "dueTomorrow": "Due tomorrow", "allDay": "All day", - "shoppingMore": "+{{count}} more" + "shoppingMore": "+{{count}} more", + "weather": "Weather", + "customize": "Customize", + "customizeTitle": "Customize widgets", + "customizeReset": "Reset", + "customizeSaved": "Dashboard saved", + "customizeMoveUp": "Move up", + "customizeMoveDown": "Move down" }, "tasks": { "title": "Tasks", diff --git a/public/locales/es.json b/public/locales/es.json index 9331124..6790aa7 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -77,7 +77,14 @@ "dueSoon": "Vence hoy", "dueTomorrow": "Vence mañana", "allDay": "Todo el día", - "shoppingMore": "+{{count}} más" + "shoppingMore": "+{{count}} más", + "weather": "Clima", + "customize": "Personalizar", + "customizeTitle": "Personalizar widgets", + "customizeReset": "Restablecer", + "customizeSaved": "Panel guardado", + "customizeMoveUp": "Subir", + "customizeMoveDown": "Bajar" }, "tasks": { "title": "Tareas", @@ -589,4 +596,4 @@ "unitMonth": "mes", "unitMonths": "meses" } -} \ No newline at end of file +} diff --git a/public/locales/fr.json b/public/locales/fr.json index 30f250c..35daea5 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -77,7 +77,14 @@ "dueSoon": "À rendre aujourd'hui", "dueTomorrow": "À rendre demain", "allDay": "Toute la journée", - "shoppingMore": "+{{count}} de plus" + "shoppingMore": "+{{count}} de plus", + "weather": "Météo", + "customize": "Personnaliser", + "customizeTitle": "Personnaliser les widgets", + "customizeReset": "Réinitialiser", + "customizeSaved": "Tableau de bord sauvegardé", + "customizeMoveUp": "Monter", + "customizeMoveDown": "Descendre" }, "tasks": { "title": "Tâches", @@ -589,4 +596,4 @@ "unitMonth": "mois", "unitMonths": "mois" } -} \ No newline at end of file +} diff --git a/public/locales/it.json b/public/locales/it.json index bdac201..5c9bcad 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -77,7 +77,14 @@ "dueSoon": "Scade oggi", "dueTomorrow": "Scade domani", "allDay": "Tutto il giorno", - "shoppingMore": "+{{count}} altri" + "shoppingMore": "+{{count}} altri", + "weather": "Meteo", + "customize": "Personalizza", + "customizeTitle": "Personalizza widget", + "customizeReset": "Ripristina", + "customizeSaved": "Dashboard salvata", + "customizeMoveUp": "Su", + "customizeMoveDown": "Giù" }, "tasks": { "title": "Compiti", @@ -589,4 +596,4 @@ "unitMonth": "mese", "unitMonths": "mesi" } -} \ No newline at end of file +} diff --git a/public/locales/ru.json b/public/locales/ru.json index c5b87a4..1b6af89 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -77,7 +77,14 @@ "dueSoon": "Сегодня", "dueTomorrow": "Завтра", "allDay": "Весь день", - "shoppingMore": "+{{count}} ещё" + "shoppingMore": "+{{count}} ещё", + "weather": "Погода", + "customize": "Настроить", + "customizeTitle": "Настроить виджеты", + "customizeReset": "Сбросить", + "customizeSaved": "Панель сохранена", + "customizeMoveUp": "Вверх", + "customizeMoveDown": "Вниз" }, "tasks": { "title": "Задачи", @@ -589,4 +596,4 @@ "unitMonth": "месяц", "unitMonths": "месяцев" } -} \ No newline at end of file +} diff --git a/public/locales/sv.json b/public/locales/sv.json index dd08dd4..bf05c1b 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -77,7 +77,14 @@ "dueSoon": "Förfaller idag", "dueTomorrow": "Förfaller imorgon", "allDay": "Hela dagen", - "shoppingMore": "+{{count}} till" + "shoppingMore": "+{{count}} till", + "weather": "Väder", + "customize": "Anpassa", + "customizeTitle": "Anpassa widgets", + "customizeReset": "Återställ", + "customizeSaved": "Instrumentpanel sparad", + "customizeMoveUp": "Flytta upp", + "customizeMoveDown": "Flytta ner" }, "tasks": { "title": "Uppgifter", @@ -589,4 +596,4 @@ "unitMonth": "månad", "unitMonths": "månader" } -} \ No newline at end of file +} diff --git a/public/locales/tr.json b/public/locales/tr.json index 1d0d282..0d7b267 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -77,7 +77,14 @@ "dueSoon": "Bugün bitiyor", "dueTomorrow": "Yarın bitiyor", "allDay": "Tüm gün", - "shoppingMore": "+{{count}} daha" + "shoppingMore": "+{{count}} daha", + "weather": "Hava", + "customize": "Özelleştir", + "customizeTitle": "Widget'ları özelleştir", + "customizeReset": "Sıfırla", + "customizeSaved": "Pano kaydedildi", + "customizeMoveUp": "Yukarı", + "customizeMoveDown": "Aşağı" }, "tasks": { "title": "Görevler", @@ -589,4 +596,4 @@ "unitMonth": "ay", "unitMonths": "ay" } -} \ No newline at end of file +} diff --git a/public/locales/zh.json b/public/locales/zh.json index 2ad86f3..06a3c9d 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -77,7 +77,14 @@ "dueSoon": "今天到期", "dueTomorrow": "明天到期", "allDay": "全天", - "shoppingMore": "+{{count}} 更多" + "shoppingMore": "+{{count}} 更多", + "weather": "天气", + "customize": "自定义", + "customizeTitle": "自定义小组件", + "customizeReset": "重置", + "customizeSaved": "仪表板已保存", + "customizeMoveUp": "上移", + "customizeMoveDown": "下移" }, "tasks": { "title": "任务", @@ -589,4 +596,4 @@ "unitMonth": "个月", "unitMonths": "个月" } -} \ No newline at end of file +} diff --git a/public/pages/dashboard.js b/public/pages/dashboard.js index 3d4f195..53c510e 100644 --- a/public/pages/dashboard.js +++ b/public/pages/dashboard.js @@ -7,10 +7,36 @@ import { api } from '/api.js'; import { t, formatDate, formatTime, getLocale } from '/i18n.js'; import { esc } 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 = ['tasks', 'calendar', 'shopping', 'meals', 'notes', 'weather']; + +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 // -------------------------------------------------------- @@ -143,10 +169,16 @@ function renderGreeting(user, stats = {}) { return `
-
-
${greeting(user.display_name)}
-
${formatDate(new Date())}
- ${statChips.length ? `
${statChips.join('')}
` : ''} +
+
+
${greeting(user.display_name)}
+
${formatDate(new Date())}
+ ${statChips.length ? `
${statChips.join('')}
` : ''} +
+
`; @@ -436,6 +468,133 @@ function initFab(container, signal) { 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 ` +
+ + + ${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; + 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; + } + }); + }, + }); +} + // -------------------------------------------------------- // Navigations-Links verdrahten // -------------------------------------------------------- @@ -467,9 +626,11 @@ export async function render(container, { user }) {
-
-
${greeting(user.display_name)}
-
${formatDate(new Date())}
+
+
+
${greeting(user.display_name)}
+
${formatDate(new Date())}
+
${skeletonWidget(3)} @@ -481,15 +642,18 @@ export async function render(container, { user }) { ${renderFab()} `; - let data = { upcomingEvents: [], urgentTasks: [], todayMeals: [], pinnedNotes: [], shoppingLists: [] }; - let weather = null; + let data = { upcomingEvents: [], urgentTasks: [], todayMeals: [], pinnedNotes: [], shoppingLists: [] }; + let weather = null; + let widgetConfig = DEFAULT_WIDGET_CONFIG; try { - const [dashRes, weatherRes] = await Promise.all([ + 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; + data = dashRes; + weather = weatherRes.data ?? null; + widgetConfig = prefsRes.data?.dashboard_widgets ?? DEFAULT_WIDGET_CONFIG; } catch (err) { console.error('[Dashboard] Ladefehler:', err.message); window.oikos?.showToast(t('dashboard.loadError'), 'warning'); @@ -506,17 +670,23 @@ export async function render(container, { user }) { ?? null, }; + function rebuildGrid(cfg) { + const grid = container.querySelector('.dashboard__grid'); + const greeting = grid?.querySelector('.widget-greeting'); + if (!grid) return; + grid.replaceChildren(...(greeting ? [greeting] : [])); + grid.insertAdjacentHTML('beforeend', renderWidgets(cfg, data, weather)); + wireLinks(container); + if (window.lucide) window.lucide.createIcons(); + wireWeatherRefresh(container); + } + 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 ?? [])} + ${renderWidgets(widgetConfig, data, weather)}
${renderFab()} @@ -526,30 +696,33 @@ export async function render(container, { user }) { initFab(container, _fabController.signal); if (window.lucide) window.lucide.createIcons(); - // Wetter-Refresh: Button + 30-Minuten-Interval + container.querySelector('#dashboard-customize-btn')?.addEventListener( + 'click', + () => openCustomizeModal(widgetConfig, (newConfig) => { + widgetConfig = newConfig; + rebuildGrid(widgetConfig); + }), + { signal: _fabController.signal }, + ); + + wireWeatherRefresh(container); + + // 30-Minuten Auto-Refresh für Wetter const refreshBtn = container.querySelector('#weather-refresh-btn'); if (refreshBtn) { - const doWeatherRefresh = async () => { - refreshBtn.disabled = true; - refreshBtn.classList.add('weather-widget__refresh--spinning'); + const doAutoRefresh = async () => { 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; + 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 }); - - // 30-Minuten Auto-Refresh - abortiert wenn Seite verlassen wird - const timerId = setInterval(doWeatherRefresh, 30 * 60 * 1000); + const timerId = setInterval(doAutoRefresh, 30 * 60 * 1000); _fabController.signal.addEventListener('abort', () => clearInterval(timerId)); } } diff --git a/public/styles/dashboard.css b/public/styles/dashboard.css index b5e50e8..4a2a408 100644 --- a/public/styles/dashboard.css +++ b/public/styles/dashboard.css @@ -98,10 +98,19 @@ grid-column: 1 / -1; } +.widget-greeting__inner { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-3); +} + .widget-greeting__content { display: flex; flex-direction: column; gap: var(--space-0h); + flex: 1; + min-width: 0; } .widget-greeting__title { @@ -1020,3 +1029,143 @@ .fab-action__btn:hover { background-color: var(--color-accent-light); } + +/* -------------------------------------------------------- + * Widget-Customize-Button (im Greeting-Header) + * -------------------------------------------------------- */ +.widget-customize-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: var(--radius-full); + background: rgba(255 255 255 / 0.18); + border: 1px solid rgba(255 255 255 / 0.3); + color: var(--color-text-on-accent); + cursor: pointer; + flex-shrink: 0; + transition: background-color var(--transition-fast); +} + +.widget-customize-btn:hover, +.widget-customize-btn:focus-visible { + background: rgba(255 255 255 / 0.3); + outline: 2px solid rgba(255 255 255 / 0.5); + outline-offset: 2px; +} + +/* -------------------------------------------------------- + * Dashboard-Customize-Modal + * -------------------------------------------------------- */ +.customize-list { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.customize-row { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-2); + border-radius: var(--radius-sm); + transition: background-color var(--transition-fast); +} + +.customize-row:hover { + background-color: var(--color-surface-raised); +} + +.customize-row__toggle { + position: relative; + display: inline-flex; + cursor: pointer; + flex-shrink: 0; +} + +.customize-row__check { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.customize-row__slider { + display: inline-block; + width: 36px; + height: 20px; + border-radius: var(--radius-full); + background-color: var(--color-border); + transition: background-color var(--transition-fast); + position: relative; +} + +.customize-row__slider::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: white; + transition: transform var(--transition-fast); + box-shadow: var(--shadow-xs); +} + +.customize-row__check:checked + .customize-row__slider { + background-color: var(--color-accent); +} + +.customize-row__check:checked + .customize-row__slider::after { + transform: translateX(16px); +} + +.customize-row__check:focus-visible + .customize-row__slider { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +.customize-row__icon { + width: 16px; + height: 16px; + flex-shrink: 0; + color: var(--color-text-secondary); +} + +.customize-row__name { + flex: 1; + font-size: var(--text-sm); + font-weight: var(--font-weight-medium); +} + +.customize-row__actions { + display: flex; + gap: var(--space-1); + flex-shrink: 0; +} + +.customize-row__btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: var(--radius-sm); + border: 1px solid var(--color-border); + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + transition: background-color var(--transition-fast), color var(--transition-fast); +} + +.customize-row__btn:hover:not(:disabled) { + background-color: var(--color-surface-raised); + color: var(--color-text); +} + +.customize-row__btn:disabled { + opacity: 0.3; + cursor: not-allowed; +} diff --git a/server/routes/preferences.js b/server/routes/preferences.js index c72eb58..fbcb73c 100644 --- a/server/routes/preferences.js +++ b/server/routes/preferences.js @@ -18,6 +18,9 @@ const DEFAULT_MEAL_TYPES = VALID_MEAL_TYPES.join(','); const VALID_CURRENCIES = ['EUR', 'USD', 'GBP', 'SEK', 'NOK', 'DKK', 'CHF', 'CNY', 'PLN', 'CZK', 'HUF', 'JPY', 'AUD', 'CAD', 'TRY', 'RUB']; const DEFAULT_CURRENCY = 'EUR'; +const VALID_WIDGET_IDS = ['tasks', 'calendar', 'shopping', 'meals', 'notes', 'weather']; +const DEFAULT_WIDGET_CONFIG = JSON.stringify(VALID_WIDGET_IDS.map((id) => ({ id, visible: true }))); + // -------------------------------------------------------- // Hilfsfunktionen // -------------------------------------------------------- @@ -36,6 +39,34 @@ function cfgSet(key, value) { `).run(key, value); } +// -------------------------------------------------------- +// Widget-Hilfsfunktionen +// -------------------------------------------------------- + +function parseWidgetConfig(raw) { + try { + const parsed = JSON.parse(raw ?? DEFAULT_WIDGET_CONFIG); + return normalizeWidgetConfig(parsed); + } catch { + return JSON.parse(DEFAULT_WIDGET_CONFIG); + } +} + +function normalizeWidgetConfig(input) { + const valid = Array.isArray(input) + ? input + .filter((w) => w && typeof w === 'object' && VALID_WIDGET_IDS.includes(w.id)) + .map((w) => ({ id: w.id, visible: Boolean(w.visible) })) + : []; + + // Fehlende Widget-IDs am Ende ergänzen + const presentIds = new Set(valid.map((w) => w.id)); + for (const id of VALID_WIDGET_IDS) { + if (!presentIds.has(id)) valid.push({ id, visible: true }); + } + return valid; +} + // -------------------------------------------------------- // GET /api/v1/preferences // Alle Haushalt-Praeferenzen lesen. @@ -47,11 +78,13 @@ router.get('/', (req, res) => { const raw = cfgGet('visible_meal_types') ?? DEFAULT_MEAL_TYPES; const visibleMealTypes = raw.split(',').filter((t) => VALID_MEAL_TYPES.includes(t)); const currency = cfgGet('currency') ?? DEFAULT_CURRENCY; + const dashboardWidgets = parseWidgetConfig(cfgGet('dashboard_widgets')); res.json({ data: { visible_meal_types: visibleMealTypes, currency, + dashboard_widgets: dashboardWidgets, }, }); } catch (err) { @@ -69,7 +102,7 @@ router.get('/', (req, res) => { router.put('/', (req, res) => { try { - const { visible_meal_types, currency } = req.body; + const { visible_meal_types, currency, dashboard_widgets } = req.body; if (visible_meal_types !== undefined) { if (!Array.isArray(visible_meal_types)) { @@ -89,14 +122,24 @@ router.put('/', (req, res) => { cfgSet('currency', currency); } + if (dashboard_widgets !== undefined) { + if (!Array.isArray(dashboard_widgets)) { + return res.status(400).json({ error: 'dashboard_widgets muss ein Array sein', code: 400 }); + } + const normalized = normalizeWidgetConfig(dashboard_widgets); + cfgSet('dashboard_widgets', JSON.stringify(normalized)); + } + const rawMealTypes = cfgGet('visible_meal_types') ?? DEFAULT_MEAL_TYPES; const savedMealTypes = rawMealTypes.split(',').filter((t) => VALID_MEAL_TYPES.includes(t)); const savedCurrency = cfgGet('currency') ?? DEFAULT_CURRENCY; + const savedWidgets = parseWidgetConfig(cfgGet('dashboard_widgets')); res.json({ data: { visible_meal_types: savedMealTypes, currency: savedCurrency, + dashboard_widgets: savedWidgets, }, }); } catch (err) {