diff --git a/public/pages/meals.js b/public/pages/meals.js index 167cd1c..ef24196 100644 --- a/public/pages/meals.js +++ b/public/pages/meals.js @@ -29,6 +29,12 @@ const DAY_NAMES = () => [ ]; const EXCLUDED_MEAL_CATEGORY_NAMES = new Set(['Haushalt', 'Drogerie']); +const MEAL_CATEGORY_OPTIONS = [ + ['meat', 'Kød'], ['fish', 'Fisk'], ['pasta', 'Pasta'], ['rice', 'Ris'], + ['vegetarian', 'Grønt'], ['soup', 'Suppe'], ['leftovers', 'Rester'], ['cozy', 'Hygge'], ['other', 'Andet'], +]; +const PROTEIN_OPTIONS = [['mixed', 'Blandet'], ['chicken', 'Kylling'], ['beef', 'Okse'], ['pork', 'Svin'], ['fish', 'Fisk'], ['vegetarian', 'Vegetar'], ['none', 'Ingen'], ['other', 'Andet']]; +const STYLE_OPTIONS = [['family', 'Familie'], ['quick', 'Hurtig'], ['cozy', 'Hygge'], ['grill', 'Grill'], ['vegetarian', 'Vegetar'], ['kids', 'Børnevenlig'], ['leftovers', 'Rester'], ['other', 'Andet']]; // -------------------------------------------------------- // State @@ -38,6 +44,7 @@ let state = { currentWeek: null, // YYYY-MM-DD (Montag) meals: [], recipes: [], + recipeSignals: [], familyMembers: [], // Familienmitglieder für Koch-Zuweisung lists: [], // Einkaufslisten für Transfer-Dropdown categories: [], // Einkaufskategorien für Zutaten @@ -83,6 +90,10 @@ function mealCategories() { return state.categories.filter((c) => !EXCLUDED_MEAL_CATEGORY_NAMES.has(c.name)); } +function optionHtml(options, selected) { + return options.map(([value, label]) => ``).join(''); +} + // -------------------------------------------------------- // API-Wrapper // -------------------------------------------------------- @@ -136,6 +147,35 @@ async function loadFamilyMembers() { } } +async function loadRecipeSignals() { + try { + const res = await api.get('/meal-planning/recipe-signals'); + state.recipeSignals = res.data; + } catch { + state.recipeSignals = []; + } +} + +function signalFor(recipeId, userId) { + return state.recipeSignals.find((signal) => Number(signal.recipe_id) === Number(recipeId) && Number(signal.user_id) === Number(userId)); +} + +async function saveRecipeSignal(recipeId, userId, patch) { + const current = signalFor(recipeId, userId) || {}; + const res = await api.put(`/meal-planning/recipe-signals/${recipeId}`, { + user_id: userId, + preference: current.preference || 'neutral', + can_cook: !!current.can_cook, + can_help_cook: !!current.can_help_cook, + will_eat_modified: !!current.will_eat_modified, + adult_only: !!current.adult_only, + ...patch, + }); + state.recipeSignals = state.recipeSignals.filter((signal) => !(Number(signal.recipe_id) === Number(recipeId) && Number(signal.user_id) === Number(userId))); + state.recipeSignals.push(res.data); + return res.data; +} + async function loadPreferences() { try { const res = await api.get('/preferences'); @@ -177,13 +217,15 @@ export async function render(container, { user }) { renderKitchenTabsBar(container, '/meals'); const today = new Date().toISOString().slice(0, 10); - const monday = getMondayOf(today); + const params = new URLSearchParams(window.location.search); + const requestedWeek = params.get('week') || params.get('date') || today; + const monday = getMondayOf(requestedWeek); - await Promise.all([loadWeek(monday), loadLists(), loadPreferences(), loadCategories(), loadRecipes(), loadFamilyMembers()]); + await Promise.all([loadWeek(monday), loadLists(), loadPreferences(), loadCategories(), loadRecipes(), loadFamilyMembers(), loadRecipeSignals()]); renderWeekGrid(); wireNav(); - const selectedRecipeId = Number(new URLSearchParams(window.location.search).get('recipe')); + const selectedRecipeId = Number(params.get('recipe')); if (selectedRecipeId) { const selectedRecipe = state.recipes.find((r) => r.id === selectedRecipeId); if (selectedRecipe) { @@ -263,6 +305,8 @@ function renderSlot(date, type, mealsForDay) { const ingDoneLabel = ingCount > 0 && ingDone === ingCount ? ' ✓' : ''; const canTransfer = ingCount > 0 && ingDone < ingCount; const cookName = meal.cook_assignment?.cook_name; + const mealCategoryLabel = meal.meal_category ? MEAL_CATEGORY_OPTIONS.find(([value]) => value === meal.meal_category)?.[1] : ''; + const leftoverLabel = meal.leftover_from_meal_id ? 'Rester' : ''; return `
@@ -272,7 +316,9 @@ function renderSlot(date, type, mealsForDay) { data-meal-id="${meal.id}" role="button" tabindex="0">
${esc(meal.title)}
- ${(ingLabel || cookName) ? `
+ ${(ingLabel || cookName || mealCategoryLabel || leftoverLabel) ? `
+ ${mealCategoryLabel ? `${esc(mealCategoryLabel)}` : ''} + ${leftoverLabel ? `↻ ${esc(leftoverLabel)}` : ''} ${ingLabel ? `${ingLabel}${esc(ingDoneLabel)}` : ''} ${cookName ? `${esc(cookName)}` : ''}
` : ''} @@ -619,6 +665,9 @@ function openMealModal(opts) { panel.querySelector('#modal-title').value = recipe.title || ''; panel.querySelector('#modal-notes').value = recipe.notes || ''; panel.querySelector('#modal-recipe-url').value = recipe.recipe_url || ''; + panel.querySelector('#modal-meal-category').value = recipe.meal_category || 'other'; + panel.querySelector('#modal-protein').value = recipe.protein || 'mixed'; + panel.querySelector('#modal-style').value = recipe.style || 'family'; ingList.innerHTML = (recipe.ingredients || []) .map((ing) => { @@ -654,6 +703,39 @@ function openMealModal(opts) { if (window.lucide) lucide.createIcons(); }); + panel.querySelector('#modal-leftover-from-meal-id')?.addEventListener('change', (event) => { + if (!event.target.value) return; + panel.querySelector('#modal-meal-category').value = 'leftovers'; + panel.querySelector('#modal-protein').value = 'none'; + panel.querySelector('#modal-style').value = 'leftovers'; + }); + + panel.querySelector('[data-meal-recipe-pref-actions]')?.addEventListener('click', async (e) => { + const btn = e.target.closest('[data-meal-recipe-pref]'); + if (!btn) return; + const recipeId = Number(panel.querySelector('#modal-recipe-id')?.value || 0); + const userId = Number(panel.querySelector('#modal-recipe-pref-member')?.value || 0); + if (!recipeId || !userId) { + window.oikos?.showToast('Choose a saved recipe and family member first.', 'error'); + return; + } + const kind = btn.dataset.mealRecipePref; + const patch = kind === 'favorite' ? { preference: 'favorite' } + : kind === 'like' ? { preference: 'like' } + : kind === 'dislike' ? { preference: 'dislike' } + : kind === 'canCook' ? { can_cook: true } + : {}; + btn.disabled = true; + try { + await saveRecipeSignal(recipeId, userId, patch); + window.oikos?.showToast('Meal signal saved to profile', 'success'); + } catch (err) { + window.oikos?.showToast(err.data?.error ?? t('common.errorGeneric'), 'error'); + } finally { + btn.disabled = false; + } + }); + saveAsRecipeBtn?.addEventListener('click', async () => { const title = panel.querySelector('#modal-title').value.trim(); if (!title) { @@ -668,10 +750,13 @@ function openMealModal(opts) { quantity: ing.quantity, category: ing.category, })); + const meal_category = panel.querySelector('#modal-meal-category')?.value || 'other'; + const protein = panel.querySelector('#modal-protein')?.value || 'mixed'; + const style = panel.querySelector('#modal-style')?.value || 'family'; saveAsRecipeBtn.disabled = true; try { - const created = await api.post('/recipes', { title, notes, recipe_url, ingredients }); + const created = await api.post('/recipes', { title, notes, recipe_url, meal_category, protein, style, ingredients }); state.recipes.push(created.data); if (recipeSelect) { @@ -772,6 +857,14 @@ function buildModalContent({ mode, date, mealType, meal }) { ...state.familyMembers.map((member) => ``), ].join(''); + const leftoverOptions = [ + ``, + ...state.meals + .filter((candidate) => !isEdit || candidate.id !== meal.id) + .slice(-20) + .map((candidate) => ``), + ].join(''); + return `