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('')}
` : ''}
+
`;
@@ -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())}
+
${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) {