From 08159ec8b42e4d0c45532a70e7be8c1d05c051dc Mon Sep 17 00:00:00 2001 From: Ulas Date: Sat, 4 Apr 2026 22:51:57 +0200 Subject: [PATCH] feat(meals): customizable meal type visibility in Settings (#14) Users can now toggle which meal types (breakfast, lunch, dinner, snack) are displayed in the meal planner via a new Settings section. Preference is stored household-wide in sync_config and applied as a filter on the meals page. Includes preferences API, i18n (DE/EN/IT), and Settings UI. --- CHANGELOG.md | 9 ++++ docs/SPEC.md | 1 + package.json | 2 +- public/locales/de.json | 7 ++- public/locales/en.json | 7 ++- public/locales/it.json | 7 ++- public/pages/meals.js | 22 ++++++--- public/pages/settings.js | 60 +++++++++++++++++++++++- public/styles/settings.css | 39 ++++++++++++++++ public/sw.js | 2 +- server/index.js | 2 + server/routes/preferences.js | 91 ++++++++++++++++++++++++++++++++++++ 12 files changed, 237 insertions(+), 12 deletions(-) create mode 100644 server/routes/preferences.js 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() { ${formatDayDate(date)}
- ${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.mealTypesLabel')}

+

${t('settings.mealTypesHint')}

+
+ + + + +
+
+
+

${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;