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.
This commit is contained in:
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.9.1] - 2026-04-04
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ Weekly view (Mon–Sun), slots: breakfast / lunch / dinner / snack.
|
|||||||
- Week navigation forward/back
|
- Week navigation forward/back
|
||||||
- Drag & drop between days/slots
|
- Drag & drop between days/slots
|
||||||
- Autocomplete from meal history
|
- 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`)
|
### Calendar (`/calendar`)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oikos",
|
"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.",
|
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -513,7 +513,12 @@
|
|||||||
"appleDisconnectConfirm": "Apple Calendar-Verbindung trennen?",
|
"appleDisconnectConfirm": "Apple Calendar-Verbindung trennen?",
|
||||||
"localeSystem": "System",
|
"localeSystem": "System",
|
||||||
"localeLabel": "Sprache",
|
"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": {
|
"login": {
|
||||||
|
|||||||
@@ -513,7 +513,12 @@
|
|||||||
"appleDisconnectConfirm": "Disconnect Apple Calendar?",
|
"appleDisconnectConfirm": "Disconnect Apple Calendar?",
|
||||||
"localeSystem": "System",
|
"localeSystem": "System",
|
||||||
"localeLabel": "Language",
|
"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": {
|
"login": {
|
||||||
|
|||||||
@@ -513,7 +513,12 @@
|
|||||||
"appleDisconnectConfirm": "Disconnettere Apple Calendar?",
|
"appleDisconnectConfirm": "Disconnettere Apple Calendar?",
|
||||||
"localeSystem": "Sistema",
|
"localeSystem": "Sistema",
|
||||||
"localeLabel": "Lingua",
|
"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": {
|
"login": {
|
||||||
|
|||||||
+12
-2
@@ -35,6 +35,7 @@ let state = {
|
|||||||
meals: [],
|
meals: [],
|
||||||
lists: [], // Einkaufslisten für Transfer-Dropdown
|
lists: [], // Einkaufslisten für Transfer-Dropdown
|
||||||
modal: null,
|
modal: null,
|
||||||
|
visibleMealTypes: ['breakfast', 'lunch', 'dinner', 'snack'],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Container-Referenz für Hilfsfunktionen (wird in render() gesetzt)
|
// 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
|
// Render
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
@@ -127,7 +137,7 @@ export async function render(container, { user }) {
|
|||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const monday = getMondayOf(today);
|
const monday = getMondayOf(today);
|
||||||
|
|
||||||
await Promise.all([loadWeek(monday), loadLists()]);
|
await Promise.all([loadWeek(monday), loadLists(), loadPreferences()]);
|
||||||
renderWeekGrid();
|
renderWeekGrid();
|
||||||
wireNav();
|
wireNav();
|
||||||
}
|
}
|
||||||
@@ -157,7 +167,7 @@ function renderWeekGrid() {
|
|||||||
<span class="day-header__date">${formatDayDate(date)}</span>
|
<span class="day-header__date">${formatDayDate(date)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="day-slots">
|
<div class="day-slots">
|
||||||
${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('')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -23,16 +23,19 @@ export async function render(container, { user }) {
|
|||||||
let users = [];
|
let users = [];
|
||||||
let googleStatus = { configured: false, connected: false, lastSync: null };
|
let googleStatus = { configured: false, connected: false, lastSync: null };
|
||||||
let appleStatus = { configured: false, lastSync: null };
|
let appleStatus = { configured: false, lastSync: null };
|
||||||
|
let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'] };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [usersRes, gStatus, aStatus] = await Promise.allSettled([
|
const [usersRes, gStatus, aStatus, prefsRes] = await Promise.allSettled([
|
||||||
user.role === 'admin' ? auth.getUsers() : Promise.resolve({ data: [] }),
|
user.role === 'admin' ? auth.getUsers() : Promise.resolve({ data: [] }),
|
||||||
api.get('/calendar/google/status'),
|
api.get('/calendar/google/status'),
|
||||||
api.get('/calendar/apple/status'),
|
api.get('/calendar/apple/status'),
|
||||||
|
api.get('/preferences'),
|
||||||
]);
|
]);
|
||||||
if (usersRes.status === 'fulfilled') users = usersRes.value.data ?? [];
|
if (usersRes.status === 'fulfilled') users = usersRes.value.data ?? [];
|
||||||
if (gStatus.status === 'fulfilled') googleStatus = gStatus.value;
|
if (gStatus.status === 'fulfilled') googleStatus = gStatus.value;
|
||||||
if (aStatus.status === 'fulfilled') appleStatus = aStatus.value;
|
if (aStatus.status === 'fulfilled') appleStatus = aStatus.value;
|
||||||
|
if (prefsRes.status === 'fulfilled') prefs = prefsRes.value.data ?? prefs;
|
||||||
} catch (_) { /* non-critical */ }
|
} catch (_) { /* non-critical */ }
|
||||||
|
|
||||||
const googleStatusText = googleStatus.connected
|
const googleStatusText = googleStatus.connected
|
||||||
@@ -84,6 +87,33 @@ export async function render(container, { user }) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Essensplan -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2 class="settings-section__title">${t('settings.sectionMeals')}</h2>
|
||||||
|
<div class="settings-card">
|
||||||
|
<h3 class="settings-card__title">${t('settings.mealTypesLabel')}</h3>
|
||||||
|
<p class="form-hint" style="margin-bottom:var(--space-3)">${t('settings.mealTypesHint')}</p>
|
||||||
|
<div class="meal-type-toggles" id="meal-type-toggles">
|
||||||
|
<label class="toggle-row">
|
||||||
|
<input type="checkbox" value="breakfast" checked>
|
||||||
|
<span>${t('meals.typeBreakfast')}</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle-row">
|
||||||
|
<input type="checkbox" value="lunch" checked>
|
||||||
|
<span>${t('meals.typeLunch')}</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle-row">
|
||||||
|
<input type="checkbox" value="dinner" checked>
|
||||||
|
<span>${t('meals.typeDinner')}</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle-row">
|
||||||
|
<input type="checkbox" value="snack" checked>
|
||||||
|
<span>${t('meals.typeSnack')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Mein Konto -->
|
<!-- Mein Konto -->
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h2 class="settings-section__title">${t('settings.sectionAccount')}</h2>
|
<h2 class="settings-section__title">${t('settings.sectionAccount')}</h2>
|
||||||
@@ -251,6 +281,14 @@ export async function render(container, { user }) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// 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);
|
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
|
// Passwort ändern
|
||||||
const passwordForm = container.querySelector('#password-form');
|
const passwordForm = container.querySelector('#password-form');
|
||||||
if (passwordForm) {
|
if (passwordForm) {
|
||||||
|
|||||||
@@ -316,6 +316,45 @@
|
|||||||
color: var(--color-accent);
|
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
|
Abmelden
|
||||||
-------------------------------------------------------- */
|
-------------------------------------------------------- */
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
* API: Immer Netzwerk (kein Caching von Nutzerdaten)
|
* 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 PAGES_CACHE = 'oikos-pages-v25';
|
||||||
const ASSETS_CACHE = 'oikos-assets-v25';
|
const ASSETS_CACHE = 'oikos-assets-v25';
|
||||||
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE];
|
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE];
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import notesRouter from './routes/notes.js';
|
|||||||
import contactsRouter from './routes/contacts.js';
|
import contactsRouter from './routes/contacts.js';
|
||||||
import budgetRouter from './routes/budget.js';
|
import budgetRouter from './routes/budget.js';
|
||||||
import weatherRouter from './routes/weather.js';
|
import weatherRouter from './routes/weather.js';
|
||||||
|
import preferencesRouter from './routes/preferences.js';
|
||||||
|
|
||||||
const log = createLogger('Server');
|
const log = createLogger('Server');
|
||||||
const logSync = createLogger('Sync');
|
const logSync = createLogger('Sync');
|
||||||
@@ -160,6 +161,7 @@ app.use('/api/v1/notes', notesRouter);
|
|||||||
app.use('/api/v1/contacts', contactsRouter);
|
app.use('/api/v1/contacts', contactsRouter);
|
||||||
app.use('/api/v1/budget', budgetRouter);
|
app.use('/api/v1/budget', budgetRouter);
|
||||||
app.use('/api/v1/weather', weatherRouter);
|
app.use('/api/v1/weather', weatherRouter);
|
||||||
|
app.use('/api/v1/preferences', preferencesRouter);
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Health-Check (für Docker)
|
// Health-Check (für Docker)
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user