diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1d570e4..1d49461 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.10.0] - 2026-04-04
+
+### Added
+- Customizable meal type visibility: toggle breakfast, lunch, dinner, snack on/off in Settings (#14)
+- New household-wide preferences API (`GET/PUT /api/v1/preferences`) using existing `sync_config` table
+- New "Meal Plan" section in Settings page with checkbox toggles per meal type
+- Meals page filters displayed slots based on household preference
+- i18n keys for meal visibility settings in DE, EN, IT
+
## [0.9.1] - 2026-04-04
### Added
diff --git a/docs/SPEC.md b/docs/SPEC.md
index 315e633..0699ac9 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -183,6 +183,7 @@ Weekly view (Mon–Sun), slots: breakfast / lunch / dinner / snack.
- Week navigation forward/back
- Drag & drop between days/slots
- Autocomplete from meal history
+- **Customizable meal visibility:** In Settings, users can toggle which meal types (breakfast, lunch, dinner, snack) are shown in the planner. Stored as household-wide preference in `sync_config` (key: `visible_meal_types`). At least one type must remain active.
### Calendar (`/calendar`)
diff --git a/package.json b/package.json
index 6e132b9..74308dc 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "oikos",
- "version": "0.9.1",
+ "version": "0.10.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",
diff --git a/public/locales/de.json b/public/locales/de.json
index 9a15f32..d54b7c0 100644
--- a/public/locales/de.json
+++ b/public/locales/de.json
@@ -513,7 +513,12 @@
"appleDisconnectConfirm": "Apple Calendar-Verbindung trennen?",
"localeSystem": "System",
"localeLabel": "Sprache",
- "languageTitle": "Sprache"
+ "languageTitle": "Sprache",
+ "sectionMeals": "Essensplan",
+ "mealTypesLabel": "Sichtbare Mahlzeiten",
+ "mealTypesHint": "Nur ausgewaehlte Mahlzeit-Typen werden im Essensplan angezeigt.",
+ "mealTypesSaved": "Essensplan-Einstellungen gespeichert.",
+ "mealTypesMinOne": "Mindestens ein Mahlzeit-Typ muss aktiv sein."
},
"login": {
diff --git a/public/locales/en.json b/public/locales/en.json
index 1a4d49b..b12fe20 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -513,7 +513,12 @@
"appleDisconnectConfirm": "Disconnect Apple Calendar?",
"localeSystem": "System",
"localeLabel": "Language",
- "languageTitle": "Language"
+ "languageTitle": "Language",
+ "sectionMeals": "Meal Plan",
+ "mealTypesLabel": "Visible meals",
+ "mealTypesHint": "Only selected meal types are shown in the meal planner.",
+ "mealTypesSaved": "Meal plan settings saved.",
+ "mealTypesMinOne": "At least one meal type must be active."
},
"login": {
diff --git a/public/locales/it.json b/public/locales/it.json
index a7d8c17..dca53e3 100644
--- a/public/locales/it.json
+++ b/public/locales/it.json
@@ -513,7 +513,12 @@
"appleDisconnectConfirm": "Disconnettere Apple Calendar?",
"localeSystem": "Sistema",
"localeLabel": "Lingua",
- "languageTitle": "Lingua"
+ "languageTitle": "Lingua",
+ "sectionMeals": "Piano pasti",
+ "mealTypesLabel": "Pasti visibili",
+ "mealTypesHint": "Solo i tipi di pasto selezionati vengono mostrati nel piano pasti.",
+ "mealTypesSaved": "Impostazioni del piano pasti salvate.",
+ "mealTypesMinOne": "Almeno un tipo di pasto deve essere attivo."
},
"login": {
diff --git a/public/pages/meals.js b/public/pages/meals.js
index 7d487be..bd53007 100644
--- a/public/pages/meals.js
+++ b/public/pages/meals.js
@@ -31,10 +31,11 @@ const DAY_NAMES = () => [
// --------------------------------------------------------
let state = {
- currentWeek: null, // YYYY-MM-DD (Montag)
- meals: [],
- lists: [], // Einkaufslisten für Transfer-Dropdown
- modal: null,
+ currentWeek: null, // YYYY-MM-DD (Montag)
+ meals: [],
+ lists: [], // Einkaufslisten für Transfer-Dropdown
+ modal: null,
+ visibleMealTypes: ['breakfast', 'lunch', 'dinner', 'snack'],
};
// Container-Referenz für Hilfsfunktionen (wird in render() gesetzt)
@@ -97,6 +98,15 @@ async function loadLists() {
}
}
+async function loadPreferences() {
+ try {
+ const res = await api.get('/preferences');
+ state.visibleMealTypes = res.data.visible_meal_types ?? state.visibleMealTypes;
+ } catch {
+ // Default beibehalten
+ }
+}
+
// --------------------------------------------------------
// Render
// --------------------------------------------------------
@@ -127,7 +137,7 @@ export async function render(container, { user }) {
const today = new Date().toISOString().slice(0, 10);
const monday = getMondayOf(today);
- await Promise.all([loadWeek(monday), loadLists()]);
+ await Promise.all([loadWeek(monday), loadLists(), loadPreferences()]);
renderWeekGrid();
wireNav();
}
@@ -157,7 +167,7 @@ function renderWeekGrid() {
- ${MEAL_TYPES().map((type) => renderSlot(date, type, mealsForDay)).join('')}
+ ${MEAL_TYPES().filter((type) => state.visibleMealTypes.includes(type.key)).map((type) => renderSlot(date, type, mealsForDay)).join('')}
`;
diff --git a/public/pages/settings.js b/public/pages/settings.js
index b419c95..f1277af 100644
--- a/public/pages/settings.js
+++ b/public/pages/settings.js
@@ -23,16 +23,19 @@ 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'] };
try {
- const [usersRes, gStatus, aStatus] = await Promise.allSettled([
+ const [usersRes, gStatus, aStatus, prefsRes] = await Promise.allSettled([
user.role === 'admin' ? auth.getUsers() : Promise.resolve({ data: [] }),
api.get('/calendar/google/status'),
api.get('/calendar/apple/status'),
+ api.get('/preferences'),
]);
if (usersRes.status === 'fulfilled') users = usersRes.value.data ?? [];
if (gStatus.status === 'fulfilled') googleStatus = gStatus.value;
if (aStatus.status === 'fulfilled') appleStatus = aStatus.value;
+ if (prefsRes.status === 'fulfilled') prefs = prefsRes.value.data ?? prefs;
} catch (_) { /* non-critical */ }
const googleStatusText = googleStatus.connected
@@ -84,6 +87,33 @@ export async function render(container, { user }) {
+
+
+ ${t('settings.sectionMeals')}
+
+
+
${t('settings.sectionAccount')}
@@ -251,6 +281,14 @@ export async function render(container, { user }) {
`;
+ // Meal-Type-Checkboxen initialisieren
+ const toggles = container.querySelector('#meal-type-toggles');
+ if (toggles) {
+ toggles.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
+ cb.checked = prefs.visible_meal_types.includes(cb.value);
+ });
+ }
+
bindEvents(container, user);
}
@@ -272,6 +310,26 @@ function bindEvents(container, user) {
});
}
+ // Meal-Type-Toggles
+ const mealToggles = container.querySelector('#meal-type-toggles');
+ if (mealToggles) {
+ mealToggles.addEventListener('change', async () => {
+ const checked = [...mealToggles.querySelectorAll('input:checked')].map((cb) => cb.value);
+ if (checked.length === 0) {
+ window.oikos?.showToast(t('settings.mealTypesMinOne'), 'error');
+ // Revert: re-check all
+ mealToggles.querySelectorAll('input').forEach((cb) => { cb.checked = true; });
+ return;
+ }
+ try {
+ await api.put('/preferences', { visible_meal_types: checked });
+ window.oikos?.showToast(t('settings.mealTypesSaved'), 'success');
+ } catch (err) {
+ window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
+ }
+ });
+ }
+
// Passwort ändern
const passwordForm = container.querySelector('#password-form');
if (passwordForm) {
diff --git a/public/styles/settings.css b/public/styles/settings.css
index 3340f1b..aa0ee50 100644
--- a/public/styles/settings.css
+++ b/public/styles/settings.css
@@ -316,6 +316,45 @@
color: var(--color-accent);
}
+/* --------------------------------------------------------
+ Meal-Type-Toggles
+ -------------------------------------------------------- */
+
+.meal-type-toggles {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.toggle-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ transition: background-color var(--transition-fast);
+ min-height: var(--target-lg);
+}
+
+.toggle-row:hover {
+ background-color: var(--color-surface-2);
+}
+
+.toggle-row input[type="checkbox"] {
+ width: 18px;
+ height: 18px;
+ accent-color: var(--color-accent);
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+.toggle-row span {
+ font-size: var(--text-sm);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-primary);
+}
+
/* --------------------------------------------------------
Abmelden
-------------------------------------------------------- */
diff --git a/public/sw.js b/public/sw.js
index 7b04d68..84d9641 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -12,7 +12,7 @@
* API: Immer Netzwerk (kein Caching von Nutzerdaten)
*/
-const SHELL_CACHE = 'oikos-shell-v25';
+const SHELL_CACHE = 'oikos-shell-v26';
const PAGES_CACHE = 'oikos-pages-v25';
const ASSETS_CACHE = 'oikos-assets-v25';
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE];
diff --git a/server/index.js b/server/index.js
index 6403e02..7147517 100644
--- a/server/index.js
+++ b/server/index.js
@@ -23,6 +23,7 @@ import notesRouter from './routes/notes.js';
import contactsRouter from './routes/contacts.js';
import budgetRouter from './routes/budget.js';
import weatherRouter from './routes/weather.js';
+import preferencesRouter from './routes/preferences.js';
const log = createLogger('Server');
const logSync = createLogger('Sync');
@@ -160,6 +161,7 @@ app.use('/api/v1/notes', notesRouter);
app.use('/api/v1/contacts', contactsRouter);
app.use('/api/v1/budget', budgetRouter);
app.use('/api/v1/weather', weatherRouter);
+app.use('/api/v1/preferences', preferencesRouter);
// --------------------------------------------------------
// Health-Check (für Docker)
diff --git a/server/routes/preferences.js b/server/routes/preferences.js
new file mode 100644
index 0000000..e9e53c9
--- /dev/null
+++ b/server/routes/preferences.js
@@ -0,0 +1,91 @@
+/**
+ * Modul: Haushalt-Einstellungen (Preferences)
+ * Zweck: REST-API fuer haushaltweite Praeferenzen (via sync_config-Tabelle)
+ * Abhängigkeiten: express, server/db.js
+ */
+
+import { createLogger } from '../logger.js';
+import express from 'express';
+import * as db from '../db.js';
+
+const log = createLogger('Preferences');
+
+const router = express.Router();
+
+const VALID_MEAL_TYPES = ['breakfast', 'lunch', 'dinner', 'snack'];
+const DEFAULT_MEAL_TYPES = VALID_MEAL_TYPES.join(',');
+
+// --------------------------------------------------------
+// Hilfsfunktionen
+// --------------------------------------------------------
+
+function cfgGet(key) {
+ const row = db.get().prepare('SELECT value FROM sync_config WHERE key = ?').get(key);
+ return row ? row.value : null;
+}
+
+function cfgSet(key, value) {
+ db.get().prepare(`
+ INSERT INTO sync_config (key, value)
+ VALUES (?, ?)
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value,
+ updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
+ `).run(key, value);
+}
+
+// --------------------------------------------------------
+// GET /api/v1/preferences
+// Alle Haushalt-Praeferenzen lesen.
+// Response: { data: { visible_meal_types: string[] } }
+// --------------------------------------------------------
+
+router.get('/', (req, res) => {
+ try {
+ const raw = cfgGet('visible_meal_types') ?? DEFAULT_MEAL_TYPES;
+ const visibleMealTypes = raw.split(',').filter((t) => VALID_MEAL_TYPES.includes(t));
+
+ res.json({
+ data: {
+ visible_meal_types: visibleMealTypes,
+ },
+ });
+ } catch (err) {
+ log.error('GET /', err);
+ res.status(500).json({ error: 'Interner Fehler', code: 500 });
+ }
+});
+
+// --------------------------------------------------------
+// PUT /api/v1/preferences
+// Haushalt-Praeferenzen aktualisieren.
+// Body: { visible_meal_types: string[] }
+// Response: { data: { visible_meal_types: string[] } }
+// --------------------------------------------------------
+
+router.put('/', (req, res) => {
+ try {
+ const { visible_meal_types } = req.body;
+
+ if (!Array.isArray(visible_meal_types)) {
+ return res.status(400).json({ error: 'visible_meal_types muss ein Array sein', code: 400 });
+ }
+
+ const filtered = visible_meal_types.filter((t) => VALID_MEAL_TYPES.includes(t));
+ if (filtered.length === 0) {
+ return res.status(400).json({ error: 'Mindestens ein Mahlzeit-Typ muss aktiv sein', code: 400 });
+ }
+
+ cfgSet('visible_meal_types', filtered.join(','));
+
+ res.json({
+ data: {
+ visible_meal_types: filtered,
+ },
+ });
+ } catch (err) {
+ log.error('PUT /', err);
+ res.status(500).json({ error: 'Interner Fehler', code: 500 });
+ }
+});
+
+export default router;