chore: release v0.42.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ulas Kalayci
2026-05-04 06:52:35 +02:00
parent 3b02cb1aee
commit 99a2280c02
10 changed files with 348 additions and 31 deletions
+6
View File
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.42.0] - 2026-05-04
### Added
- **Module toggles** (Settings → General, admin-only): individual modules (Tasks, Calendar, Shopping, Meals, Recipes, Birthdays, Notes, Contacts, Budget, Documents) can be disabled to hide them from the navigation. Data is preserved and reappears when the module is re-enabled. Dashboard and Settings remain essential and cannot be disabled.
- **Bulk actions for tasks** (List view only): select multiple tasks via checkboxes and apply batch operations (mark done, mark open, archive, delete). Bulk select toggle appears in the toolbar; selected count and action bar appear when tasks are checked. Kanban view remains single-task oriented.
## [0.41.0] - 2026-05-01
### Added
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "oikos",
"version": "0.41.0",
"version": "0.42.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "oikos",
"version": "0.41.0",
"version": "0.42.0",
"license": "MIT",
"dependencies": {
"bcrypt": "^6.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "oikos",
"version": "0.41.0",
"version": "0.42.0",
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
"main": "server/index.js",
"type": "module",
+15
View File
@@ -210,6 +210,17 @@
"statusArchived": "Archiviert",
"archiveButton": "Aufgabe archivieren",
"archivedToast": "Aufgabe archiviert.",
"bulkSelect": "Mehrfachauswahl",
"selectTask": "Aufgabe auswählen",
"bulkSelectedCount": "{{count}} ausgewählt",
"bulkMarkDone": "Erledigt",
"bulkMarkOpen": "Offen",
"bulkArchive": "Archivieren",
"bulkDelete": "Löschen",
"bulkDeleteConfirm": "{{count}} Aufgaben unwiderruflich löschen?",
"bulkStatusChanged": "Status geändert.",
"bulkArchived": "Aufgaben archiviert.",
"bulkDeleted": "Aufgaben gelöscht.",
"kanbanArchived": "Archiviert",
"reminderNeedsDueDate": "Lege ein Fälligkeitsdatum fest, um Aufgabenerinnerungen zu aktivieren."
},
@@ -755,6 +766,10 @@
"tabsAriaLabel": "Einstellungsbereiche",
"sectionDesign": "Design",
"sectionAppName": "Anwendungsname",
"sectionModules": "Module",
"modulesTitle": "Aktive Module",
"modulesHint": "Deaktivierte Module verschwinden aus der Navigation. Daten bleiben erhalten und können nach Reaktivierung wieder genutzt werden.",
"modulesSaved": "Modul-Sichtbarkeit gespeichert.",
"sectionShopping": "Einkauf",
"shoppingCategoriesLabel": "Einkaufskategorien",
"shoppingCategoriesHint": "Kategorien hinzufügen, umbenennen, löschen oder sortieren.",
+15
View File
@@ -184,6 +184,17 @@
"createdToast": "Task created.",
"deletedToast": "Task deleted.",
"archivedToast": "Task archived.",
"bulkSelect": "Bulk select",
"selectTask": "Select task",
"bulkSelectedCount": "{{count}} selected",
"bulkMarkDone": "Mark done",
"bulkMarkOpen": "Mark open",
"bulkArchive": "Archive",
"bulkDelete": "Delete",
"bulkDeleteConfirm": "Delete {{count}} tasks permanently?",
"bulkStatusChanged": "Status changed.",
"bulkArchived": "Tasks archived.",
"bulkDeleted": "Tasks deleted.",
"loadError": "Task could not be loaded.",
"subtaskPrompt": "Subtask:",
"kanbanOpen": "Open",
@@ -749,6 +760,10 @@
"tabsAriaLabel": "Settings sections",
"sectionDesign": "Appearance",
"sectionAppName": "Application name",
"sectionModules": "Modules",
"modulesTitle": "Active modules",
"modulesHint": "Disabled modules disappear from the navigation. Data is preserved and reappears once a module is re-enabled.",
"modulesSaved": "Module visibility saved.",
"sectionShopping": "Shopping",
"shoppingCategoriesLabel": "Shopping Categories",
"shoppingCategoriesHint": "Add, rename, delete or reorder categories.",
+46 -1
View File
@@ -199,7 +199,7 @@ export async function render(container, { user }) {
let users = [];
let googleStatus = { configured: false, connected: false, lastSync: null };
let appleStatus = { configured: false, lastSync: null };
let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR', date_format: 'mdy', time_format: '24h', app_name: DEFAULT_APP_NAME };
let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR', date_format: 'mdy', time_format: '24h', app_name: DEFAULT_APP_NAME, disabled_modules: [] };
let categories = [];
let icsSubscriptions = [];
let apiTokens = [];
@@ -359,6 +359,35 @@ export async function render(container, { user }) {
<oikos-locale-picker></oikos-locale-picker>
</div>
</section>
${user?.role === 'admin' ? `
<section class="settings-section">
<h2 class="settings-section__title">${t('settings.sectionModules')}</h2>
<div class="settings-card">
<h3 class="settings-card__title">${t('settings.modulesTitle')}</h3>
<p class="form-hint" style="margin-bottom:var(--space-3)">${t('settings.modulesHint')}</p>
<div class="meal-type-toggles" id="module-toggles">
${[
['tasks', 'nav.tasks'],
['calendar', 'nav.calendar'],
['meals', 'nav.meals'],
['recipes', 'nav.recipes'],
['shopping', 'nav.shopping'],
['birthdays', 'nav.birthdays'],
['notes', 'nav.notes'],
['contacts', 'nav.contacts'],
['budget', 'nav.budget'],
['documents', 'nav.documents'],
].map(([slug, labelKey]) => `
<label class="toggle-row">
<input type="checkbox" value="${slug}" ${prefs.disabled_modules?.includes(slug) ? '' : 'checked'}>
<span>${t(labelKey)}</span>
</label>
`).join('')}
</div>
</div>
</section>
` : ''}
</div>
<!-- Panel: Mahlzeiten -->
@@ -823,6 +852,22 @@ function bindEvents(container, user, users, categories, icsSubscriptions, apiTok
});
}
// Modul-Toggles (admin-only)
const moduleToggles = container.querySelector('#module-toggles');
if (moduleToggles) {
moduleToggles.addEventListener('change', async () => {
const disabled = [...moduleToggles.querySelectorAll('input:not(:checked)')].map((cb) => cb.value);
try {
const res = await api.put('/preferences', { disabled_modules: disabled });
const saved = res?.data?.disabled_modules ?? disabled;
window.oikos?.setDisabledModules?.(saved);
window.oikos?.showToast(t('settings.modulesSaved'), 'success');
} catch (err) {
window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
}
});
}
// Meal-Type-Toggles
const mealToggles = container.querySelector('#meal-type-toggles');
if (mealToggles) {
+135 -2
View File
@@ -159,7 +159,7 @@ function renderSwipeRow(task, innerHtml) {
}
function renderTaskCard(task, opts = {}) {
const { expandedSubtasks = false } = opts;
const { expandedSubtasks = false, showCheckbox = false, isChecked = false } = opts;
const isDone = task.status === 'done';
const progress = task.subtask_total > 0
? Math.round((task.subtask_done / task.subtask_total) * 100)
@@ -181,6 +181,10 @@ function renderTaskCard(task, opts = {}) {
return `
<div class="task-card ${isDone ? 'task-card--done' : ''}" data-task-id="${task.id}">
<div class="task-card__main">
${showCheckbox ? `
<input type="checkbox" class="task-bulk-checkbox" data-task-id="${task.id}"
${isChecked ? 'checked' : ''} aria-label="${t('tasks.selectTask')}">
` : ''}
<button class="task-status-btn task-status-btn--${task.status}"
data-action="toggle-status" data-id="${task.id}" data-status="${task.status}"
aria-label="${isDone ? t('tasks.markOpen', { title: esc(task.title) }) : t('tasks.markDone', { title: esc(task.title) })}">
@@ -286,7 +290,10 @@ function renderTaskGroups(tasks, groupMode) {
<span class="task-group__title">${catLabelsMap[name] ?? name}</span>
<span class="task-group__count">${groupTasks.length}</span>
</div>
${sorted.map((t) => renderSwipeRow(t, renderTaskCard(t))).join('')}
${sorted.map((t) => renderSwipeRow(t, renderTaskCard(t, {
showCheckbox: state.bulkSelectMode,
isChecked: state.selectedTaskIds.has(t.id),
}))).join('')}
</div>`;
}).join('');
}
@@ -414,6 +421,8 @@ let state = {
expandedTasks: new Set(),
dragTaskId: null,
filterPanelOpen: false,
bulkSelectMode: false,
selectedTaskIds: new Set(),
};
// --------------------------------------------------------
@@ -1504,11 +1513,21 @@ function wireViewToggle(container) {
);
const groupToggle = container.querySelector('#group-mode-toggle');
if (groupToggle) groupToggle.style.display = state.viewMode === 'list' ? '' : 'none';
const bulkSelectBtn = container.querySelector('#btn-bulk-select');
if (bulkSelectBtn) {
bulkSelectBtn.style.display = state.viewMode === 'list' ? '' : 'none';
if (state.viewMode === 'kanban') {
state.bulkSelectMode = false;
state.selectedTaskIds.clear();
bulkSelectBtn.classList.remove('btn--active');
}
}
// Skeleton-Flash: einen Frame Render-Feedback geben, dann Ansicht aufbauen
const listEl = container.querySelector('#task-list');
if (listEl) listEl.style.opacity = '0.4';
requestAnimationFrame(() => {
renderTaskList(container);
updateBulkActionsBar(container);
const el = container.querySelector('#task-list');
if (el) { el.style.transition = 'opacity 0.15s'; el.style.opacity = ''; }
});
@@ -1538,6 +1557,92 @@ function wireNewTaskBtn(container) {
container.querySelector('#fab-new-task')?.addEventListener('click', handler);
}
function updateBulkActionsBar(container) {
const bar = container.querySelector('#bulk-actions-bar');
const count = container.querySelector('#bulk-count');
if (!bar) return;
const selected = state.selectedTaskIds.size;
if (selected === 0) {
bar.hidden = true;
} else {
bar.hidden = false;
count.textContent = t('tasks.bulkSelectedCount', { count: selected });
}
}
function wireBulkSelect(container) {
const toggleBtn = container.querySelector('#btn-bulk-select');
if (!toggleBtn) return;
toggleBtn.addEventListener('click', () => {
state.bulkSelectMode = !state.bulkSelectMode;
if (!state.bulkSelectMode) {
state.selectedTaskIds.clear();
}
toggleBtn.classList.toggle('btn--active', state.bulkSelectMode);
loadTasks(container);
});
}
function wireBulkCheckboxes(container) {
const listEl = container.querySelector('#task-list');
if (!listEl) return;
listEl.addEventListener('change', (e) => {
const checkbox = e.target.closest('.task-bulk-checkbox');
if (!checkbox) return;
const taskId = Number(checkbox.dataset.taskId);
if (checkbox.checked) {
state.selectedTaskIds.add(taskId);
} else {
state.selectedTaskIds.delete(taskId);
}
updateBulkActionsBar(container);
});
}
function wireBulkActions(container) {
const bar = container.querySelector('#bulk-actions-bar');
if (!bar) return;
bar.addEventListener('click', async (e) => {
const btn = e.target.closest('button[id^="bulk-"]');
if (!btn) return;
const taskIds = [...state.selectedTaskIds];
if (taskIds.length === 0) return;
const action = btn.id;
let confirm = true;
if (action === 'bulk-delete') {
confirm = window.confirm(t('tasks.bulkDeleteConfirm', { count: taskIds.length }));
}
if (!confirm) return;
try {
if (action === 'bulk-mark-done' || action === 'bulk-mark-open') {
const status = btn.dataset.status;
await Promise.all(taskIds.map(id => api.patch(`/tasks/${id}/status`, { status })));
window.oikos.showToast(t('tasks.bulkStatusChanged'), 'success');
} else if (action === 'bulk-archive') {
await Promise.all(taskIds.map(id => api.patch(`/tasks/${id}/status`, { status: 'archived' })));
window.oikos.showToast(t('tasks.bulkArchived'), 'success');
} else if (action === 'bulk-delete') {
await Promise.all(taskIds.map(id => api.delete(`/tasks/${id}`)));
window.oikos.showToast(t('tasks.bulkDeleted'), 'success');
}
state.selectedTaskIds.clear();
updateBulkActionsBar(container);
await loadTasks(container);
} catch (err) {
window.oikos.showToast(err.message ?? t('common.errorGeneric'), 'danger');
}
});
}
function wireTaskList(container) {
const listEl = container.querySelector('#task-list');
if (!listEl) return;
@@ -1638,6 +1743,10 @@ export async function render(container, { user }) {
<i data-lucide="columns" class="icon-md" aria-hidden="true"></i>
</button>
</div>
<button class="btn btn--ghost btn--icon" id="btn-bulk-select" ${isKanban ? 'style="display:none"' : ''}
title="${t('tasks.bulkSelect')}" aria-label="${t('tasks.bulkSelect')}">
<i data-lucide="list-checks" class="icon-lg" aria-hidden="true"></i>
</button>
<button class="btn btn--primary" id="btn-new-task" style="gap:var(--space-1)">
<i data-lucide="plus" class="icon-lg" aria-hidden="true"></i> ${t('tasks.newTask')}
</button>
@@ -1647,6 +1756,27 @@ export async function render(container, { user }) {
<div class="tasks-body">
<div class="tasks-filters" id="filter-bar"></div>
<div class="filter-panel" id="filter-panel" hidden></div>
<div class="bulk-actions-bar" id="bulk-actions-bar" hidden>
<span class="bulk-actions-bar__count" id="bulk-count"></span>
<div class="bulk-actions-bar__actions">
<button class="btn btn--secondary btn--sm" id="bulk-mark-done" data-status="done">
<i data-lucide="check" class="icon-base" aria-hidden="true"></i>
${t('tasks.bulkMarkDone')}
</button>
<button class="btn btn--secondary btn--sm" id="bulk-mark-open" data-status="open">
<i data-lucide="rotate-ccw" class="icon-base" aria-hidden="true"></i>
${t('tasks.bulkMarkOpen')}
</button>
<button class="btn btn--secondary btn--sm" id="bulk-archive">
<i data-lucide="archive" class="icon-base" aria-hidden="true"></i>
${t('tasks.bulkArchive')}
</button>
<button class="btn btn--danger btn--sm" id="bulk-delete">
<i data-lucide="trash-2" class="icon-base" aria-hidden="true"></i>
${t('tasks.bulkDelete')}
</button>
</div>
</div>
<div id="task-list">
${[1,2,3].map(() => `
@@ -1691,6 +1821,9 @@ export async function render(container, { user }) {
wireGroupToggle(container);
wireNewTaskBtn(container);
wireTaskList(container);
wireBulkSelect(container);
wireBulkCheckboxes(container);
wireBulkActions(container);
renderFilters(container);
renderTaskList(container);
+56 -20
View File
@@ -121,6 +121,7 @@ let currentUser = null;
let currentPath = null;
let isNavigating = false;
let _preferencesLoaded = false;
let _disabledModules = new Set();
// Gesetzt wenn auth:expired waehrend einer laufenden Navigation feuert.
// Die Weiterleitung zu /login wird nach Abschluss der Navigation nachgeholt.
let _pendingLoginRedirect = false;
@@ -239,7 +240,15 @@ async function navigate(path, userOrPushState = true, pushState = true) {
const basePath = path.split('?')[0];
currentPath = basePath;
const route = ROUTES.find((r) => r.path === basePath) ?? ROUTES.find((r) => r.path === '/');
let route = ROUTES.find((r) => r.path === basePath) ?? ROUTES.find((r) => r.path === '/');
// Modul-Guard: deaktivierte Module leiten auf Dashboard um.
if (route.module && _disabledModules.has(route.module) && route.path !== '/') {
currentPath = null;
isNavigating = false;
navigate('/');
return;
}
// Auth-Guard
if (route.requiresAuth && !currentUser) {
@@ -308,6 +317,9 @@ async function syncPreferencesOnce() {
setAppName(res.data.app_name);
updateBranding();
}
if (Array.isArray(res?.data?.disabled_modules)) {
_disabledModules = new Set(res.data.disabled_modules);
}
} catch {
// Non-critical. The settings page can refresh this later.
}
@@ -981,22 +993,32 @@ function renderSearchResults(container, data, onClose) {
}
function navItems() {
return [
{ path: '/', label: t('nav.dashboard'), icon: 'layout-dashboard' },
{ path: '/calendar', label: t('nav.calendar'), icon: 'calendar' },
const all = [
{ path: '/', label: t('nav.dashboard'), icon: 'layout-dashboard', module: 'dashboard' },
{ path: '/calendar', label: t('nav.calendar'), icon: 'calendar', module: 'calendar' },
// More-Sheet Items:
{ path: '/tasks', label: t('nav.tasks'), icon: 'check-square' },
{ path: '/birthdays', label: t('nav.birthdays'), icon: 'cake' },
{ path: '/notes', label: t('nav.notes'), icon: 'sticky-note' },
{ path: '/contacts', label: t('nav.contacts'), icon: 'book-user' },
{ path: '/budget', label: t('nav.budget'), icon: 'wallet' },
{ path: '/documents', label: t('nav.documents'), icon: 'folder-lock' },
{ path: '/settings', label: t('nav.settings'), icon: 'settings' },
{ path: '/tasks', label: t('nav.tasks'), icon: 'check-square', module: 'tasks' },
{ path: '/birthdays', label: t('nav.birthdays'), icon: 'cake', module: 'birthdays' },
{ path: '/notes', label: t('nav.notes'), icon: 'sticky-note', module: 'notes' },
{ path: '/contacts', label: t('nav.contacts'), icon: 'book-user', module: 'contacts' },
{ path: '/budget', label: t('nav.budget'), icon: 'wallet', module: 'budget' },
{ path: '/documents', label: t('nav.documents'), icon: 'folder-lock', module: 'documents' },
{ path: '/settings', label: t('nav.settings'), icon: 'settings', module: 'settings' },
// Kitchen-Gruppe: via Küche-Nav-Button (Bottom-Nav + Sidebar) + kitchen-tabs-bar erreichbar
{ path: '/meals', label: t('nav.meals'), icon: 'utensils', kitchenGroup: true },
{ path: '/recipes', label: t('nav.recipes'), icon: 'book-text', kitchenGroup: true },
{ path: '/shopping', label: t('nav.shopping'), icon: 'shopping-cart', kitchenGroup: true },
{ path: '/meals', label: t('nav.meals'), icon: 'utensils', module: 'meals', kitchenGroup: true },
{ path: '/recipes', label: t('nav.recipes'), icon: 'book-text', module: 'recipes', kitchenGroup: true },
{ path: '/shopping', label: t('nav.shopping'), icon: 'shopping-cart', module: 'shopping', kitchenGroup: true },
];
return all.filter((item) => !_disabledModules.has(item.module));
}
function isModuleDisabled(moduleName) {
return _disabledModules.has(moduleName);
}
function setDisabledModules(modules) {
_disabledModules = new Set(Array.isArray(modules) ? modules : []);
rebuildNavigation();
}
function navItemEl({ path, label, icon }) {
@@ -1276,8 +1298,9 @@ window.addEventListener('auth:expired', () => {
}
});
// Sprache geändert: Navigation neu rendern damit Labels aktualisiert werden
window.addEventListener('locale-changed', () => {
// Navigation komplett neu rendern (z.B. nach Sprach- oder Modul-Toggle-Änderung).
// Behält Bottom-Bar-Buttons (Kitchen, More) und More-Sheet-Handle/Suche bei.
function rebuildNavigation({ updateLabels = true } = {}) {
const skipLink = document.querySelector('.sr-only[href="#main-content"]');
const navSidebar = document.querySelector('.nav-sidebar');
const navSidebarItems = document.querySelector('.nav-sidebar__items');
@@ -1286,10 +1309,14 @@ window.addEventListener('locale-changed', () => {
const moreSheet = document.querySelector('#more-sheet');
const moreBtnLabel = document.querySelector('#more-btn .nav-item__label');
if (updateLabels) {
if (skipLink) skipLink.textContent = t('common.skipToContent');
if (navSidebar) navSidebar.setAttribute('aria-label', t('nav.main'));
if (navBottom) navBottom.setAttribute('aria-label', t('nav.navigation'));
if (moreBtnLabel) moreBtnLabel.textContent = t('nav.more');
}
const kitchenVisible = ['meals', 'recipes', 'shopping'].some((m) => !_disabledModules.has(m));
if (navSidebarItems) {
const sidebarEls = [];
@@ -1297,7 +1324,7 @@ window.addEventListener('locale-changed', () => {
.filter((item) => !item.kitchenGroup)
.forEach((item) => {
sidebarEls.push(navItemEl(item));
if (item.path === '/calendar') sidebarEls.push(sidebarKitchenEl());
if (item.path === '/calendar' && kitchenVisible) sidebarEls.push(sidebarKitchenEl());
});
navSidebarItems.replaceChildren(...sidebarEls);
if (window.lucide) window.lucide.createIcons({ el: navSidebarItems });
@@ -1305,9 +1332,13 @@ window.addEventListener('locale-changed', () => {
if (bottomItems) {
const kitchenBtnEl = bottomItems.querySelector('#kitchen-btn');
const moreBtn = bottomItems.querySelector('#more-btn');
if (kitchenBtnEl) kitchenBtnEl.querySelector('.nav-item__label').textContent = t('nav.kitchen');
if (kitchenBtnEl) {
kitchenBtnEl.querySelector('.nav-item__label').textContent = t('nav.kitchen');
kitchenBtnEl.hidden = !kitchenVisible;
}
const newItems = navItems().slice(0, PRIMARY_NAV).map(navItemEl);
bottomItems.replaceChildren(...newItems, kitchenBtnEl, moreBtn);
const tail = [kitchenBtnEl, moreBtn].filter(Boolean);
bottomItems.replaceChildren(...newItems, ...tail);
}
if (moreSheet) {
const handle = moreSheet.querySelector('.more-sheet__handle');
@@ -1330,7 +1361,10 @@ window.addEventListener('locale-changed', () => {
updateNav(currentPath);
updateBranding(currentPath || '/');
});
}
// Sprache geändert: Navigation neu rendern damit Labels aktualisiert werden
window.addEventListener('locale-changed', () => rebuildNavigation());
window.addEventListener('app-name-changed', () => {
updateBranding(currentPath || '/');
@@ -1422,6 +1456,8 @@ window.oikos = {
showToast,
friendlyError,
setThemeColor,
setDisabledModules,
isModuleDisabled,
applyTheme: (value) => {
localStorage.setItem('oikos-theme', value);
if (value === 'dark') {
+35
View File
@@ -776,3 +776,38 @@
opacity: 0.5;
}
/* --------------------------------------------------------
Bulk selection
-------------------------------------------------------- */
.task-bulk-checkbox {
width: 20px;
height: 20px;
margin-right: var(--space-2);
cursor: pointer;
flex-shrink: 0;
}
.bulk-actions-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3);
margin-bottom: var(--space-3);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.bulk-actions-bar__count {
font-weight: 600;
color: var(--color-text-primary);
}
.bulk-actions-bar__actions {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
+33 -1
View File
@@ -28,6 +28,13 @@ const DEFAULT_TIME_FORMAT = '24h';
const VALID_WIDGET_IDS = ['tasks', 'calendar', 'weather', 'meals', 'shopping', 'birthdays', 'budget', 'family', 'notes'];
const VALID_WIDGET_SIZES = ['1x1', '1x2', '1x3', '1x4', '2x1', '2x2', '2x3', '2x4', '3x1', '3x2', '3x3', '3x4', '4x1', '4x2', '4x3', '4x4'];
// Modul-Slugs, die per Settings deaktiviert werden können.
// Dashboard und Settings sind absichtlich nicht enthalten — sie sind essentiell.
const TOGGLEABLE_MODULES = [
'tasks', 'calendar', 'meals', 'recipes', 'shopping',
'birthdays', 'notes', 'contacts', 'budget', 'documents',
];
function defaultWidgetSize(id) {
if (['tasks', 'calendar'].includes(id)) return '2x2';
if (['weather', 'shopping', 'notes'].includes(id)) return '2x1';
@@ -76,6 +83,17 @@ function parseWidgetConfig(raw) {
}
}
function parseDisabledModules(raw) {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((m) => typeof m === 'string' && TOGGLEABLE_MODULES.includes(m));
} catch {
return [];
}
}
function normalizeWidgetConfig(input) {
const valid = Array.isArray(input)
? input
@@ -115,6 +133,7 @@ router.get('/', (req, res) => {
const timeFormat = VALID_TIME_FORMATS.includes(cfgGet('time_format')) ? cfgGet('time_format') : DEFAULT_TIME_FORMAT;
const appName = cfgGet('app_name') ?? DEFAULT_APP_NAME;
const dashboardWidgets = parseWidgetConfig(cfgGet('dashboard_widgets'));
const disabledModules = parseDisabledModules(cfgGet('disabled_modules'));
res.json({
data: {
@@ -124,6 +143,7 @@ router.get('/', (req, res) => {
time_format: timeFormat,
app_name: appName,
dashboard_widgets: dashboardWidgets,
disabled_modules: disabledModules,
},
});
} catch (err) {
@@ -141,7 +161,7 @@ router.get('/', (req, res) => {
router.put('/', (req, res) => {
try {
const { visible_meal_types, currency, date_format, time_format, app_name, dashboard_widgets } = req.body;
const { visible_meal_types, currency, date_format, time_format, app_name, dashboard_widgets, disabled_modules } = req.body;
if (visible_meal_types !== undefined) {
if (!Array.isArray(visible_meal_types)) {
@@ -190,6 +210,16 @@ router.put('/', (req, res) => {
cfgSet('dashboard_widgets', JSON.stringify(normalized));
}
if (disabled_modules !== undefined) {
if (!Array.isArray(disabled_modules)) {
return res.status(400).json({ error: 'disabled_modules muss ein Array sein', code: 400 });
}
const filtered = disabled_modules
.filter((m) => typeof m === 'string' && TOGGLEABLE_MODULES.includes(m));
const unique = [...new Set(filtered)];
cfgSet('disabled_modules', JSON.stringify(unique));
}
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;
@@ -197,6 +227,7 @@ router.put('/', (req, res) => {
const savedTimeFormat = VALID_TIME_FORMATS.includes(cfgGet('time_format')) ? cfgGet('time_format') : DEFAULT_TIME_FORMAT;
const savedAppName = cfgGet('app_name') ?? DEFAULT_APP_NAME;
const savedWidgets = parseWidgetConfig(cfgGet('dashboard_widgets'));
const savedDisabledModules = parseDisabledModules(cfgGet('disabled_modules'));
res.json({
data: {
@@ -206,6 +237,7 @@ router.put('/', (req, res) => {
time_format: savedTimeFormat,
app_name: savedAppName,
dashboard_widgets: savedWidgets,
disabled_modules: savedDisabledModules,
},
});
} catch (err) {