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:
Ulas
2026-04-04 22:51:57 +02:00
parent d472e9b9e8
commit 08159ec8b4
12 changed files with 237 additions and 12 deletions
+16 -6
View File
@@ -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() {
<span class="day-header__date">${formatDayDate(date)}</span>
</div>
<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>
`;
+59 -1
View File
@@ -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 }) {
</div>
</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 -->
<section class="settings-section">
<h2 class="settings-section__title">${t('settings.sectionAccount')}</h2>
@@ -251,6 +281,14 @@ export async function render(container, { user }) {
</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);
}
@@ -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) {