feat: structure meal planning taxonomy and favorites
This commit is contained in:
+137
-6
@@ -29,6 +29,12 @@ const DAY_NAMES = () => [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const EXCLUDED_MEAL_CATEGORY_NAMES = new Set(['Haushalt', 'Drogerie']);
|
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
|
// State
|
||||||
@@ -38,6 +44,7 @@ let state = {
|
|||||||
currentWeek: null, // YYYY-MM-DD (Montag)
|
currentWeek: null, // YYYY-MM-DD (Montag)
|
||||||
meals: [],
|
meals: [],
|
||||||
recipes: [],
|
recipes: [],
|
||||||
|
recipeSignals: [],
|
||||||
familyMembers: [], // Familienmitglieder für Koch-Zuweisung
|
familyMembers: [], // Familienmitglieder für Koch-Zuweisung
|
||||||
lists: [], // Einkaufslisten für Transfer-Dropdown
|
lists: [], // Einkaufslisten für Transfer-Dropdown
|
||||||
categories: [], // Einkaufskategorien für Zutaten
|
categories: [], // Einkaufskategorien für Zutaten
|
||||||
@@ -83,6 +90,10 @@ function mealCategories() {
|
|||||||
return state.categories.filter((c) => !EXCLUDED_MEAL_CATEGORY_NAMES.has(c.name));
|
return state.categories.filter((c) => !EXCLUDED_MEAL_CATEGORY_NAMES.has(c.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function optionHtml(options, selected) {
|
||||||
|
return options.map(([value, label]) => `<option value="${esc(value)}" ${value === selected ? 'selected' : ''}>${esc(label)}</option>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// API-Wrapper
|
// 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() {
|
async function loadPreferences() {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/preferences');
|
const res = await api.get('/preferences');
|
||||||
@@ -177,13 +217,15 @@ export async function render(container, { user }) {
|
|||||||
renderKitchenTabsBar(container, '/meals');
|
renderKitchenTabsBar(container, '/meals');
|
||||||
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
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();
|
renderWeekGrid();
|
||||||
wireNav();
|
wireNav();
|
||||||
|
|
||||||
const selectedRecipeId = Number(new URLSearchParams(window.location.search).get('recipe'));
|
const selectedRecipeId = Number(params.get('recipe'));
|
||||||
if (selectedRecipeId) {
|
if (selectedRecipeId) {
|
||||||
const selectedRecipe = state.recipes.find((r) => r.id === selectedRecipeId);
|
const selectedRecipe = state.recipes.find((r) => r.id === selectedRecipeId);
|
||||||
if (selectedRecipe) {
|
if (selectedRecipe) {
|
||||||
@@ -263,6 +305,8 @@ function renderSlot(date, type, mealsForDay) {
|
|||||||
const ingDoneLabel = ingCount > 0 && ingDone === ingCount ? ' ✓' : '';
|
const ingDoneLabel = ingCount > 0 && ingDone === ingCount ? ' ✓' : '';
|
||||||
const canTransfer = ingCount > 0 && ingDone < ingCount;
|
const canTransfer = ingCount > 0 && ingDone < ingCount;
|
||||||
const cookName = meal.cook_assignment?.cook_name;
|
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 `
|
return `
|
||||||
<div class="meal-slot meal-slot--has-meal" data-meal-id="${meal.id}" data-date="${meal.date}" data-type="${type.key}">
|
<div class="meal-slot meal-slot--has-meal" data-meal-id="${meal.id}" data-date="${meal.date}" data-type="${type.key}">
|
||||||
@@ -272,7 +316,9 @@ function renderSlot(date, type, mealsForDay) {
|
|||||||
data-meal-id="${meal.id}"
|
data-meal-id="${meal.id}"
|
||||||
role="button" tabindex="0">
|
role="button" tabindex="0">
|
||||||
<div class="meal-card__title">${esc(meal.title)}</div>
|
<div class="meal-card__title">${esc(meal.title)}</div>
|
||||||
${(ingLabel || cookName) ? `<div class="meal-card__meta">
|
${(ingLabel || cookName || mealCategoryLabel || leftoverLabel) ? `<div class="meal-card__meta">
|
||||||
|
${mealCategoryLabel ? `<span class="meal-card__ingredients-count">${esc(mealCategoryLabel)}</span>` : ''}
|
||||||
|
${leftoverLabel ? `<span class="meal-card__ingredients-count">↻ ${esc(leftoverLabel)}</span>` : ''}
|
||||||
${ingLabel ? `<span class="meal-card__ingredients-count">${ingLabel}${esc(ingDoneLabel)}</span>` : ''}
|
${ingLabel ? `<span class="meal-card__ingredients-count">${ingLabel}${esc(ingDoneLabel)}</span>` : ''}
|
||||||
${cookName ? `<span class="meal-card__cook"><i data-lucide="chef-hat" style="width:13px;height:13px;" aria-hidden="true"></i>${esc(cookName)}</span>` : ''}
|
${cookName ? `<span class="meal-card__cook"><i data-lucide="chef-hat" style="width:13px;height:13px;" aria-hidden="true"></i>${esc(cookName)}</span>` : ''}
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
@@ -619,6 +665,9 @@ function openMealModal(opts) {
|
|||||||
panel.querySelector('#modal-title').value = recipe.title || '';
|
panel.querySelector('#modal-title').value = recipe.title || '';
|
||||||
panel.querySelector('#modal-notes').value = recipe.notes || '';
|
panel.querySelector('#modal-notes').value = recipe.notes || '';
|
||||||
panel.querySelector('#modal-recipe-url').value = recipe.recipe_url || '';
|
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 || [])
|
ingList.innerHTML = (recipe.ingredients || [])
|
||||||
.map((ing) => {
|
.map((ing) => {
|
||||||
@@ -654,6 +703,39 @@ function openMealModal(opts) {
|
|||||||
if (window.lucide) lucide.createIcons();
|
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 () => {
|
saveAsRecipeBtn?.addEventListener('click', async () => {
|
||||||
const title = panel.querySelector('#modal-title').value.trim();
|
const title = panel.querySelector('#modal-title').value.trim();
|
||||||
if (!title) {
|
if (!title) {
|
||||||
@@ -668,10 +750,13 @@ function openMealModal(opts) {
|
|||||||
quantity: ing.quantity,
|
quantity: ing.quantity,
|
||||||
category: ing.category,
|
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;
|
saveAsRecipeBtn.disabled = true;
|
||||||
try {
|
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);
|
state.recipes.push(created.data);
|
||||||
|
|
||||||
if (recipeSelect) {
|
if (recipeSelect) {
|
||||||
@@ -772,6 +857,14 @@ function buildModalContent({ mode, date, mealType, meal }) {
|
|||||||
...state.familyMembers.map((member) => `<option value="${member.id}" ${selectedCookId === String(member.id) ? 'selected' : ''}>${esc(member.display_name)}</option>`),
|
...state.familyMembers.map((member) => `<option value="${member.id}" ${selectedCookId === String(member.id) ? 'selected' : ''}>${esc(member.display_name)}</option>`),
|
||||||
].join('');
|
].join('');
|
||||||
|
|
||||||
|
const leftoverOptions = [
|
||||||
|
`<option value="">Ingen rester</option>`,
|
||||||
|
...state.meals
|
||||||
|
.filter((candidate) => !isEdit || candidate.id !== meal.id)
|
||||||
|
.slice(-20)
|
||||||
|
.map((candidate) => `<option value="${candidate.id}" ${isEdit && meal.leftover_from_meal_id === candidate.id ? 'selected' : ''}>${esc(candidate.date)} · ${esc(candidate.title)}</option>`),
|
||||||
|
].join('');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="modal-grid modal-grid--2">
|
<div class="modal-grid modal-grid--2">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -803,6 +896,40 @@ function buildModalContent({ mode, date, mealType, meal }) {
|
|||||||
<select class="form-input" id="modal-recipe-id">${recipeOptions}</select>
|
<select class="form-input" id="modal-recipe-id">${recipeOptions}</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" data-meal-recipe-pref-actions>
|
||||||
|
<label class="form-label" for="modal-recipe-pref-member">Save this meal as a person-specific signal</label>
|
||||||
|
<select class="form-input" id="modal-recipe-pref-member">
|
||||||
|
<option value="">Choose family member</option>
|
||||||
|
${state.familyMembers.map((member) => `<option value="${member.id}">${esc(member.display_name)}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
<div class="recipe-card__actions" style="margin-top:var(--space-2)">
|
||||||
|
<button type="button" class="btn btn--ghost" data-meal-recipe-pref="favorite">⭐ Favorite</button>
|
||||||
|
<button type="button" class="btn btn--ghost" data-meal-recipe-pref="like">👍 Likes</button>
|
||||||
|
<button type="button" class="btn btn--ghost" data-meal-recipe-pref="dislike">👎 Dislikes</button>
|
||||||
|
<button type="button" class="btn btn--ghost" data-meal-recipe-pref="canCook">👩🍳 Can cook</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-grid modal-grid--3">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="modal-meal-category">Meal category</label>
|
||||||
|
<select class="form-input" id="modal-meal-category">${optionHtml(MEAL_CATEGORY_OPTIONS, isEdit ? (meal.meal_category || 'other') : 'other')}</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="modal-protein">Protein</label>
|
||||||
|
<select class="form-input" id="modal-protein">${optionHtml(PROTEIN_OPTIONS, isEdit ? (meal.protein || 'mixed') : 'mixed')}</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="modal-style">Style</label>
|
||||||
|
<select class="form-input" id="modal-style">${optionHtml(STYLE_OPTIONS, isEdit ? (meal.style || 'family') : 'family')}</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="modal-leftover-from-meal-id">Use leftovers from a specific dish</label>
|
||||||
|
<select class="form-input" id="modal-leftover-from-meal-id">${leftoverOptions}</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-grid modal-grid--2">
|
<div class="modal-grid modal-grid--2">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="modal-recipe-scale">${t('meals.recipeScaleLabel')}</label>
|
<label class="form-label" for="modal-recipe-scale">${t('meals.recipeScaleLabel')}</label>
|
||||||
@@ -888,6 +1015,10 @@ async function saveModal(overlay) {
|
|||||||
const notes = overlay.querySelector('#modal-notes').value.trim() || null;
|
const notes = overlay.querySelector('#modal-notes').value.trim() || null;
|
||||||
const recipe_url = overlay.querySelector('#modal-recipe-url').value.trim() || null;
|
const recipe_url = overlay.querySelector('#modal-recipe-url').value.trim() || null;
|
||||||
const recipe_id = overlay.querySelector('#modal-recipe-id')?.value || null;
|
const recipe_id = overlay.querySelector('#modal-recipe-id')?.value || null;
|
||||||
|
const meal_category = overlay.querySelector('#modal-meal-category')?.value || 'other';
|
||||||
|
const protein = overlay.querySelector('#modal-protein')?.value || 'mixed';
|
||||||
|
const style = overlay.querySelector('#modal-style')?.value || 'family';
|
||||||
|
const leftover_from_meal_id = overlay.querySelector('#modal-leftover-from-meal-id')?.value || null;
|
||||||
const cookSelect = overlay.querySelector('#modal-cook-user-id');
|
const cookSelect = overlay.querySelector('#modal-cook-user-id');
|
||||||
const cook_user_id = cookSelect?.value ? Number(cookSelect.value) : null;
|
const cook_user_id = cookSelect?.value ? Number(cookSelect.value) : null;
|
||||||
|
|
||||||
@@ -909,7 +1040,7 @@ async function saveModal(overlay) {
|
|||||||
try {
|
try {
|
||||||
const { mode, meal } = state.modal;
|
const { mode, meal } = state.modal;
|
||||||
|
|
||||||
const mealPayload = { date, meal_type, title, notes, recipe_url, recipe_id, cook_user_id };
|
const mealPayload = { date, meal_type, title, notes, recipe_url, recipe_id, meal_category, protein, style, leftover_from_meal_id, cook_user_id };
|
||||||
|
|
||||||
if (mode === 'create') {
|
if (mode === 'create') {
|
||||||
const res = await api.post('/meals', { ...mealPayload, ingredients });
|
const res = await api.post('/meals', { ...mealPayload, ingredients });
|
||||||
|
|||||||
+126
-3
@@ -8,14 +8,28 @@ import { t } from '/i18n.js';
|
|||||||
import { openModal as openSharedModal, closeModal as closeSharedModal } from '/components/modal.js';
|
import { openModal as openSharedModal, closeModal as closeSharedModal } from '/components/modal.js';
|
||||||
import { DEFAULT_CATEGORY_NAME, categoryLabel } from '/utils/shopping-categories.js';
|
import { DEFAULT_CATEGORY_NAME, categoryLabel } from '/utils/shopping-categories.js';
|
||||||
import { renderKitchenTabsBar } from '/utils/kitchen-tabs.js';
|
import { renderKitchenTabsBar } from '/utils/kitchen-tabs.js';
|
||||||
|
import { esc } from '/utils/html.js';
|
||||||
|
|
||||||
let _container = null;
|
let _container = null;
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
recipes: [],
|
recipes: [],
|
||||||
categories: [],
|
categories: [],
|
||||||
|
familyMembers: [],
|
||||||
|
recipeSignals: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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']];
|
||||||
|
|
||||||
|
function optionHtml(options, selected) {
|
||||||
|
return options.map(([value, label]) => `<option value="${value}" ${value === selected ? 'selected' : ''}>${label}</option>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
function mealCategories() {
|
function mealCategories() {
|
||||||
return state.categories.filter((c) => c.name !== 'Haushalt' && c.name !== 'Drogerie');
|
return state.categories.filter((c) => c.name !== 'Haushalt' && c.name !== 'Drogerie');
|
||||||
}
|
}
|
||||||
@@ -34,6 +48,45 @@ async function loadCategories() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadFamilyMembers() {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/family/members');
|
||||||
|
state.familyMembers = res.data;
|
||||||
|
} catch { state.familyMembers = []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecipeSignals() {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/meal-planning/recipe-signals');
|
||||||
|
state.recipeSignals = res.data;
|
||||||
|
} catch { state.recipeSignals = []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function signalsForRecipe(recipeId) {
|
||||||
|
return state.recipeSignals.filter((signal) => Number(signal.recipe_id) === Number(recipeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 payload = {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
const res = await api.put(`/meal-planning/recipe-signals/${recipeId}`, payload);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
export async function render(container) {
|
export async function render(container) {
|
||||||
_container = container;
|
_container = container;
|
||||||
|
|
||||||
@@ -75,7 +128,7 @@ export async function render(container) {
|
|||||||
|
|
||||||
if (window.lucide) window.lucide.createIcons();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
|
|
||||||
await Promise.all([loadRecipes(), loadCategories()]);
|
await Promise.all([loadRecipes(), loadCategories(), loadFamilyMembers(), loadRecipeSignals()]);
|
||||||
renderRecipeList();
|
renderRecipeList();
|
||||||
|
|
||||||
addBtn.addEventListener('click', () => openRecipeModal('create'));
|
addBtn.addEventListener('click', () => openRecipeModal('create'));
|
||||||
@@ -107,6 +160,13 @@ export async function render(container) {
|
|||||||
if (actionBtn.dataset.action === 'add-to-meals') {
|
if (actionBtn.dataset.action === 'add-to-meals') {
|
||||||
window.oikos?.navigate(`/meals?recipe=${recipe.id}`);
|
window.oikos?.navigate(`/meals?recipe=${recipe.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (actionBtn.dataset.action === 'quick-favorite') {
|
||||||
|
const memberId = Number(actionBtn.dataset.memberId);
|
||||||
|
await saveRecipeSignal(recipe.id, memberId, { preference: 'favorite' });
|
||||||
|
renderRecipeList();
|
||||||
|
window.oikos?.showToast('Favorit gemt på profilen', 'success');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +215,27 @@ function renderRecipeList() {
|
|||||||
|
|
||||||
card.appendChild(h);
|
card.appendChild(h);
|
||||||
|
|
||||||
|
const taxonomy = [
|
||||||
|
recipe.meal_category ? MEAL_CATEGORY_OPTIONS.find(([v]) => v === recipe.meal_category)?.[1] || recipe.meal_category : '',
|
||||||
|
recipe.protein ? PROTEIN_OPTIONS.find(([v]) => v === recipe.protein)?.[1] || recipe.protein : '',
|
||||||
|
recipe.style ? STYLE_OPTIONS.find(([v]) => v === recipe.style)?.[1] || recipe.style : '',
|
||||||
|
].filter(Boolean);
|
||||||
|
if (taxonomy.length) {
|
||||||
|
const meta = document.createElement('p');
|
||||||
|
meta.className = 'recipe-card__notes';
|
||||||
|
meta.textContent = `Kategori: ${taxonomy.join(' · ')}`;
|
||||||
|
card.appendChild(meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipeSignals = signalsForRecipe(recipe.id);
|
||||||
|
const favoriteSignals = recipeSignals.filter((signal) => signal.preference === 'favorite');
|
||||||
|
if (favoriteSignals.length) {
|
||||||
|
const fav = document.createElement('p');
|
||||||
|
fav.className = 'recipe-card__notes';
|
||||||
|
fav.textContent = `⭐ Favorit hos ${favoriteSignals.map((signal) => signal.user_name).filter(Boolean).join(', ')}`;
|
||||||
|
card.appendChild(fav);
|
||||||
|
}
|
||||||
|
|
||||||
if (recipe.notes) {
|
if (recipe.notes) {
|
||||||
const notes = document.createElement('p');
|
const notes = document.createElement('p');
|
||||||
notes.className = 'recipe-card__notes';
|
notes.className = 'recipe-card__notes';
|
||||||
@@ -220,6 +301,26 @@ function renderRecipeList() {
|
|||||||
actions.append(addToMeals, edit, duplicate, del);
|
actions.append(addToMeals, edit, duplicate, del);
|
||||||
card.appendChild(actions);
|
card.appendChild(actions);
|
||||||
|
|
||||||
|
if (state.familyMembers.length) {
|
||||||
|
const pref = document.createElement('div');
|
||||||
|
pref.className = 'recipe-card__actions';
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'recipe-card__notes';
|
||||||
|
label.textContent = 'Gem som favorit for:';
|
||||||
|
pref.appendChild(label);
|
||||||
|
for (const member of state.familyMembers.slice(0, 6)) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'btn btn--ghost';
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.dataset.action = 'quick-favorite';
|
||||||
|
btn.dataset.id = String(recipe.id);
|
||||||
|
btn.dataset.memberId = String(member.id);
|
||||||
|
btn.textContent = `⭐ ${member.display_name}`;
|
||||||
|
pref.appendChild(btn);
|
||||||
|
}
|
||||||
|
card.appendChild(pref);
|
||||||
|
}
|
||||||
|
|
||||||
list.appendChild(card);
|
list.appendChild(card);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -298,6 +399,24 @@ function openRecipeModal(mode, recipe = null) {
|
|||||||
<label class="form-label" for="recipe-url">${t('recipes.urlLabel')}</label>
|
<label class="form-label" for="recipe-url">${t('recipes.urlLabel')}</label>
|
||||||
<input id="recipe-url" class="form-input" type="url" placeholder="${t('recipes.urlPlaceholder')}">
|
<input id="recipe-url" class="form-input" type="url" placeholder="${t('recipes.urlPlaceholder')}">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="modal-grid modal-grid--3">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="recipe-meal-category">Meal category</label>
|
||||||
|
<select id="recipe-meal-category" class="form-input">${optionHtml(MEAL_CATEGORY_OPTIONS, recipe?.meal_category || 'other')}</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="recipe-protein">Protein</label>
|
||||||
|
<select id="recipe-protein" class="form-input">${optionHtml(PROTEIN_OPTIONS, recipe?.protein || 'mixed')}</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="recipe-style">Style</label>
|
||||||
|
<select id="recipe-style" class="form-input">${optionHtml(STYLE_OPTIONS, recipe?.style || 'family')}</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="recipe-tags">Tags</label>
|
||||||
|
<input id="recipe-tags" class="form-input" type="text" placeholder="hurtig, børnevenlig, fredag" value="${esc(Array.isArray(recipe?.tags) ? recipe.tags.join(', ') : '')}">
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">${t('recipes.ingredientsLabel')}</label>
|
<label class="form-label">${t('recipes.ingredientsLabel')}</label>
|
||||||
<div class="recipe-ingredient-list" id="recipe-ingredient-list"></div>
|
<div class="recipe-ingredient-list" id="recipe-ingredient-list"></div>
|
||||||
@@ -348,6 +467,10 @@ async function saveRecipe(panel, mode, recipe) {
|
|||||||
const title = panel.querySelector('#recipe-title')?.value.trim() || '';
|
const title = panel.querySelector('#recipe-title')?.value.trim() || '';
|
||||||
const notes = panel.querySelector('#recipe-notes')?.value.trim() || null;
|
const notes = panel.querySelector('#recipe-notes')?.value.trim() || null;
|
||||||
const recipe_url = panel.querySelector('#recipe-url')?.value.trim() || null;
|
const recipe_url = panel.querySelector('#recipe-url')?.value.trim() || null;
|
||||||
|
const meal_category = panel.querySelector('#recipe-meal-category')?.value || 'other';
|
||||||
|
const protein = panel.querySelector('#recipe-protein')?.value || 'mixed';
|
||||||
|
const style = panel.querySelector('#recipe-style')?.value || 'family';
|
||||||
|
const tags = (panel.querySelector('#recipe-tags')?.value || '').split(',').map((tag) => tag.trim()).filter(Boolean);
|
||||||
|
|
||||||
if (!title) {
|
if (!title) {
|
||||||
window.oikos?.showToast(t('recipes.titleRequired'), 'error');
|
window.oikos?.showToast(t('recipes.titleRequired'), 'error');
|
||||||
@@ -366,10 +489,10 @@ async function saveRecipe(panel, mode, recipe) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (mode === 'create') {
|
if (mode === 'create') {
|
||||||
const res = await api.post('/recipes', { title, notes, recipe_url, ingredients });
|
const res = await api.post('/recipes', { title, notes, recipe_url, meal_category, protein, style, tags, ingredients });
|
||||||
state.recipes.push(res.data);
|
state.recipes.push(res.data);
|
||||||
} else {
|
} else {
|
||||||
const res = await api.put(`/recipes/${recipe.id}`, { title, notes, recipe_url, ingredients });
|
const res = await api.put(`/recipes/${recipe.id}`, { title, notes, recipe_url, meal_category, protein, style, tags, ingredients });
|
||||||
const idx = state.recipes.findIndex((r) => r.id === recipe.id);
|
const idx = state.recipes.findIndex((r) => r.id === recipe.id);
|
||||||
if (idx >= 0) state.recipes[idx] = res.data;
|
if (idx >= 0) state.recipes[idx] = res.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2334,6 +2334,8 @@ function memberHtml(u) {
|
|||||||
u.phone ? t('settings.memberPhoneMeta', { value: u.phone }) : '',
|
u.phone ? t('settings.memberPhoneMeta', { value: u.phone }) : '',
|
||||||
u.email || '',
|
u.email || '',
|
||||||
u.birth_date ? t('settings.memberBirthdayMeta', { date: formatDate(u.birth_date) }) : '',
|
u.birth_date ? t('settings.memberBirthdayMeta', { date: formatDate(u.birth_date) }) : '',
|
||||||
|
Number(u.favorite_meal_count || 0) ? `⭐ ${Number(u.favorite_meal_count)} favorite meals` : '',
|
||||||
|
Number(u.can_cook_meal_count || 0) ? `👩🍳 ${Number(u.can_cook_meal_count)} can cook` : '',
|
||||||
].filter(Boolean).map(esc).join(' · ');
|
].filter(Boolean).map(esc).join(' · ');
|
||||||
return `
|
return `
|
||||||
<li class="settings-member" data-id="${u.id}">
|
<li class="settings-member" data-id="${u.id}">
|
||||||
|
|||||||
+3
-1
@@ -31,7 +31,9 @@ const USER_PUBLIC_COLUMNS = `
|
|||||||
created_at,
|
created_at,
|
||||||
(SELECT phone FROM contacts WHERE contacts.family_user_id = users.id LIMIT 1) AS phone,
|
(SELECT phone FROM contacts WHERE contacts.family_user_id = users.id LIMIT 1) AS phone,
|
||||||
(SELECT email FROM contacts WHERE contacts.family_user_id = users.id LIMIT 1) AS email,
|
(SELECT email FROM contacts WHERE contacts.family_user_id = users.id LIMIT 1) AS email,
|
||||||
(SELECT birth_date FROM birthdays WHERE birthdays.family_user_id = users.id LIMIT 1) AS birth_date
|
(SELECT birth_date FROM birthdays WHERE birthdays.family_user_id = users.id LIMIT 1) AS birth_date,
|
||||||
|
(SELECT COUNT(*) FROM recipe_family_preferences WHERE recipe_family_preferences.user_id = users.id AND preference = 'favorite') AS favorite_meal_count,
|
||||||
|
(SELECT COUNT(*) FROM recipe_family_preferences WHERE recipe_family_preferences.user_id = users.id AND can_cook = 1) AS can_cook_meal_count
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|||||||
@@ -1482,6 +1482,34 @@ const MIGRATIONS = [
|
|||||||
`);
|
`);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
version: 41,
|
||||||
|
description: 'Structured meal taxonomy and leftover links',
|
||||||
|
up(database) {
|
||||||
|
const columns = (table) => new Set(database.prepare(`PRAGMA table_info(${table})`).all().map((row) => row.name));
|
||||||
|
const addColumn = (table, name, definition) => {
|
||||||
|
if (!columns(table).has(name)) database.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
addColumn('recipes', 'meal_category', "TEXT");
|
||||||
|
addColumn('recipes', 'protein', "TEXT");
|
||||||
|
addColumn('recipes', 'style', "TEXT");
|
||||||
|
addColumn('recipes', 'tags_json', "TEXT");
|
||||||
|
|
||||||
|
addColumn('meals', 'meal_category', "TEXT");
|
||||||
|
addColumn('meals', 'protein', "TEXT");
|
||||||
|
addColumn('meals', 'style', "TEXT");
|
||||||
|
addColumn('meals', 'leftover_from_meal_id', "INTEGER REFERENCES meals(id) ON DELETE SET NULL");
|
||||||
|
addColumn('meals', 'source_plan_id', "TEXT");
|
||||||
|
|
||||||
|
database.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recipes_meal_taxonomy ON recipes(meal_category, protein, style);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_meals_meal_taxonomy ON meals(meal_category, protein, style);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_meals_leftover_from ON meals(leftover_from_meal_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_meals_source_plan ON meals(source_plan_id);
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ router.get('/members', (req, res) => {
|
|||||||
c.phone,
|
c.phone,
|
||||||
c.email,
|
c.email,
|
||||||
b.birth_date,
|
b.birth_date,
|
||||||
|
(SELECT COUNT(*) FROM recipe_family_preferences p WHERE p.user_id = u.id AND p.preference = 'favorite') AS favorite_meal_count,
|
||||||
|
(SELECT COUNT(*) FROM recipe_family_preferences p WHERE p.user_id = u.id AND p.can_cook = 1) AS can_cook_meal_count,
|
||||||
u.created_at
|
u.created_at
|
||||||
FROM users u
|
FROM users u
|
||||||
LEFT JOIN contacts c ON c.family_user_id = u.id
|
LEFT JOIN contacts c ON c.family_user_id = u.id
|
||||||
|
|||||||
+49
-5
@@ -15,6 +15,9 @@ const log = createLogger('Meals');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const VALID_MEAL_TYPES = ['breakfast', 'lunch', 'dinner', 'snack'];
|
const VALID_MEAL_TYPES = ['breakfast', 'lunch', 'dinner', 'snack'];
|
||||||
|
const VALID_MEAL_CATEGORIES = ['meat', 'fish', 'pasta', 'rice', 'vegetarian', 'soup', 'leftovers', 'cozy', 'breakfast', 'snack', 'other'];
|
||||||
|
const VALID_PROTEINS = ['mixed', 'chicken', 'beef', 'pork', 'fish', 'vegetarian', 'none', 'other'];
|
||||||
|
const VALID_STYLES = ['family', 'quick', 'cozy', 'grill', 'vegetarian', 'kids', 'leftovers', 'other'];
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Hilfsfunktionen
|
// Hilfsfunktionen
|
||||||
@@ -75,6 +78,21 @@ function currentUserId(req) {
|
|||||||
return req.authUserId ?? req.session?.userId ?? null;
|
return req.authUserId ?? req.session?.userId ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeEnum(value, allowed, fallback = null) {
|
||||||
|
const normalized = String(value || '').trim().toLowerCase();
|
||||||
|
return allowed.includes(normalized) ? normalized : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateLeftoverMealId(raw) {
|
||||||
|
if (raw === undefined) return { present: false, value: null, error: null };
|
||||||
|
if (raw === null || raw === '') return { present: true, value: null, error: null };
|
||||||
|
const id = Number(raw);
|
||||||
|
if (!Number.isInteger(id) || id <= 0) return { present: true, value: null, error: 'Leftover source meal ID is invalid.' };
|
||||||
|
const exists = db.get().prepare('SELECT id FROM meals WHERE id = ?').get(id);
|
||||||
|
if (!exists) return { present: true, value: null, error: 'Leftover source meal not found.' };
|
||||||
|
return { present: true, value: id, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
function tableColumns(table) {
|
function tableColumns(table) {
|
||||||
return new Set(db.get().prepare(`PRAGMA table_info(${table})`).all().map((row) => row.name));
|
return new Set(db.get().prepare(`PRAGMA table_info(${table})`).all().map((row) => row.name));
|
||||||
}
|
}
|
||||||
@@ -224,7 +242,7 @@ router.get('/', (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* POST /api/v1/meals
|
* POST /api/v1/meals
|
||||||
* Neue Mahlzeit anlegen.
|
* Neue Mahlzeit anlegen.
|
||||||
* Body: { date, meal_type, title, notes?, cook_user_id?, source_plan_id?, ingredients?: [{ name, quantity? }] }
|
* Body: { date, meal_type, title, notes?, meal_category?, protein?, style?, leftover_from_meal_id?, cook_user_id?, source_plan_id?, ingredients?: [{ name, quantity? }] }
|
||||||
* Response: { data: Meal }
|
* Response: { data: Meal }
|
||||||
*/
|
*/
|
||||||
router.post('/', (req, res) => {
|
router.post('/', (req, res) => {
|
||||||
@@ -238,12 +256,19 @@ router.post('/', (req, res) => {
|
|||||||
const vRecipeId = num(req.body.recipe_id, 'Rezept-ID', { required: false });
|
const vRecipeId = num(req.body.recipe_id, 'Rezept-ID', { required: false });
|
||||||
const cookUserRaw = Object.hasOwn(req.body, 'cook_user_id') ? req.body.cook_user_id : req.body.cookUserId;
|
const cookUserRaw = Object.hasOwn(req.body, 'cook_user_id') ? req.body.cook_user_id : req.body.cookUserId;
|
||||||
const vCookUserId = validateCookUserId(cookUserRaw);
|
const vCookUserId = validateCookUserId(cookUserRaw);
|
||||||
|
const leftoverRaw = Object.hasOwn(req.body, 'leftover_from_meal_id') ? req.body.leftover_from_meal_id : req.body.leftoverFromMealId;
|
||||||
|
const vLeftoverFromMealId = validateLeftoverMealId(leftoverRaw);
|
||||||
const vSourcePlanId = str(req.body.source_plan_id ?? req.body.sourcePlanId, 'Plan-ID', { max: MAX_SHORT, required: false });
|
const vSourcePlanId = str(req.body.source_plan_id ?? req.body.sourcePlanId, 'Plan-ID', { max: MAX_SHORT, required: false });
|
||||||
const errors = collectErrors([vDate, vType, vTitle, vNotes, vRecipeUrl, vRecipeId, vSourcePlanId]);
|
const errors = collectErrors([vDate, vType, vTitle, vNotes, vRecipeUrl, vRecipeId, vSourcePlanId]);
|
||||||
if (vCookUserId.error) errors.push(vCookUserId.error);
|
if (vCookUserId.error) errors.push(vCookUserId.error);
|
||||||
|
if (vLeftoverFromMealId.error) errors.push(vLeftoverFromMealId.error);
|
||||||
if (!req.body.meal_type) errors.push('Mahlzeit-Typ ist erforderlich.');
|
if (!req.body.meal_type) errors.push('Mahlzeit-Typ ist erforderlich.');
|
||||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||||
|
|
||||||
|
const mealCategory = normalizeEnum(req.body.meal_category ?? req.body.mealCategory, VALID_MEAL_CATEGORIES, vLeftoverFromMealId.value ? 'leftovers' : 'other');
|
||||||
|
const protein = normalizeEnum(req.body.protein, VALID_PROTEINS, vLeftoverFromMealId.value ? 'none' : 'mixed');
|
||||||
|
const style = normalizeEnum(req.body.style, VALID_STYLES, vLeftoverFromMealId.value ? 'leftovers' : 'family');
|
||||||
|
|
||||||
if (vRecipeId.value !== null) {
|
if (vRecipeId.value !== null) {
|
||||||
const recipeExists = db.get().prepare('SELECT id FROM recipes WHERE id = ?').get(vRecipeId.value);
|
const recipeExists = db.get().prepare('SELECT id FROM recipes WHERE id = ?').get(vRecipeId.value);
|
||||||
if (!recipeExists) return res.status(400).json({ error: 'Rezept nicht gefunden.', code: 400 });
|
if (!recipeExists) return res.status(400).json({ error: 'Rezept nicht gefunden.', code: 400 });
|
||||||
@@ -251,9 +276,9 @@ router.post('/', (req, res) => {
|
|||||||
|
|
||||||
const meal = db.transaction(() => {
|
const meal = db.transaction(() => {
|
||||||
const result = db.get().prepare(`
|
const result = db.get().prepare(`
|
||||||
INSERT INTO meals (date, meal_type, title, notes, recipe_url, recipe_id, created_by)
|
INSERT INTO meals (date, meal_type, title, notes, recipe_url, recipe_id, meal_category, protein, style, leftover_from_meal_id, source_plan_id, created_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(vDate.value, vType.value, vTitle.value, vNotes.value, vRecipeUrl.value, vRecipeId.value, currentUserId(req));
|
`).run(vDate.value, vType.value, vTitle.value, vNotes.value, vRecipeUrl.value, vRecipeId.value, mealCategory, protein, style, vLeftoverFromMealId.value, vSourcePlanId.value, currentUserId(req));
|
||||||
|
|
||||||
const mealId = result.lastInsertRowid;
|
const mealId = result.lastInsertRowid;
|
||||||
|
|
||||||
@@ -316,9 +341,12 @@ router.put('/:id', (req, res) => {
|
|||||||
if (req.body.recipe_id !== undefined) checks.push(num(req.body.recipe_id, 'Rezept-ID', { required: false }));
|
if (req.body.recipe_id !== undefined) checks.push(num(req.body.recipe_id, 'Rezept-ID', { required: false }));
|
||||||
const cookUserRaw = Object.hasOwn(req.body, 'cook_user_id') ? req.body.cook_user_id : req.body.cookUserId;
|
const cookUserRaw = Object.hasOwn(req.body, 'cook_user_id') ? req.body.cook_user_id : req.body.cookUserId;
|
||||||
const vCookUserId = validateCookUserId(cookUserRaw);
|
const vCookUserId = validateCookUserId(cookUserRaw);
|
||||||
|
const leftoverRaw = Object.hasOwn(req.body, 'leftover_from_meal_id') ? req.body.leftover_from_meal_id : req.body.leftoverFromMealId;
|
||||||
|
const vLeftoverFromMealId = validateLeftoverMealId(leftoverRaw);
|
||||||
const vSourcePlanId = str(req.body.source_plan_id ?? req.body.sourcePlanId, 'Plan-ID', { max: MAX_SHORT, required: false });
|
const vSourcePlanId = str(req.body.source_plan_id ?? req.body.sourcePlanId, 'Plan-ID', { max: MAX_SHORT, required: false });
|
||||||
const errors = collectErrors([...checks, vSourcePlanId]);
|
const errors = collectErrors([...checks, vSourcePlanId]);
|
||||||
if (vCookUserId.error) errors.push(vCookUserId.error);
|
if (vCookUserId.error) errors.push(vCookUserId.error);
|
||||||
|
if (vLeftoverFromMealId.error) errors.push(vLeftoverFromMealId.error);
|
||||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||||
|
|
||||||
if (req.body.recipe_id !== undefined && req.body.recipe_id !== null && req.body.recipe_id !== '') {
|
if (req.body.recipe_id !== undefined && req.body.recipe_id !== null && req.body.recipe_id !== '') {
|
||||||
@@ -326,6 +354,12 @@ router.put('/:id', (req, res) => {
|
|||||||
if (!recipeExists) return res.status(400).json({ error: 'Rezept nicht gefunden.', code: 400 });
|
if (!recipeExists) return res.status(400).json({ error: 'Rezept nicht gefunden.', code: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mealCategory = req.body.meal_category !== undefined || req.body.mealCategory !== undefined
|
||||||
|
? normalizeEnum(req.body.meal_category ?? req.body.mealCategory, VALID_MEAL_CATEGORIES, meal.meal_category || 'other')
|
||||||
|
: meal.meal_category;
|
||||||
|
const protein = req.body.protein !== undefined ? normalizeEnum(req.body.protein, VALID_PROTEINS, meal.protein || 'mixed') : meal.protein;
|
||||||
|
const style = req.body.style !== undefined ? normalizeEnum(req.body.style, VALID_STYLES, meal.style || 'family') : meal.style;
|
||||||
|
|
||||||
db.get().prepare(`
|
db.get().prepare(`
|
||||||
UPDATE meals
|
UPDATE meals
|
||||||
SET date = COALESCE(?, date),
|
SET date = COALESCE(?, date),
|
||||||
@@ -333,7 +367,12 @@ router.put('/:id', (req, res) => {
|
|||||||
title = COALESCE(?, title),
|
title = COALESCE(?, title),
|
||||||
notes = ?,
|
notes = ?,
|
||||||
recipe_url = ?,
|
recipe_url = ?,
|
||||||
recipe_id = ?
|
recipe_id = ?,
|
||||||
|
meal_category = ?,
|
||||||
|
protein = ?,
|
||||||
|
style = ?,
|
||||||
|
leftover_from_meal_id = ?,
|
||||||
|
source_plan_id = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(
|
`).run(
|
||||||
req.body.date ?? null,
|
req.body.date ?? null,
|
||||||
@@ -342,6 +381,11 @@ router.put('/:id', (req, res) => {
|
|||||||
req.body.notes !== undefined ? (req.body.notes || null) : meal.notes,
|
req.body.notes !== undefined ? (req.body.notes || null) : meal.notes,
|
||||||
req.body.recipe_url !== undefined ? (req.body.recipe_url || null) : meal.recipe_url,
|
req.body.recipe_url !== undefined ? (req.body.recipe_url || null) : meal.recipe_url,
|
||||||
req.body.recipe_id !== undefined ? (req.body.recipe_id || null) : meal.recipe_id,
|
req.body.recipe_id !== undefined ? (req.body.recipe_id || null) : meal.recipe_id,
|
||||||
|
mealCategory,
|
||||||
|
protein,
|
||||||
|
style,
|
||||||
|
vLeftoverFromMealId.present ? vLeftoverFromMealId.value : meal.leftover_from_meal_id,
|
||||||
|
req.body.source_plan_id !== undefined || req.body.sourcePlanId !== undefined ? vSourcePlanId.value : meal.source_plan_id,
|
||||||
id
|
id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,22 @@ import { str, num, collectErrors, MAX_TITLE, MAX_TEXT, MAX_SHORT } from '../midd
|
|||||||
const log = createLogger('Recipes');
|
const log = createLogger('Recipes');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
const VALID_MEAL_CATEGORIES = ['meat', 'fish', 'pasta', 'rice', 'vegetarian', 'soup', 'leftovers', 'cozy', 'breakfast', 'snack', 'other'];
|
||||||
|
const VALID_PROTEINS = ['mixed', 'chicken', 'beef', 'pork', 'fish', 'vegetarian', 'none', 'other'];
|
||||||
|
const VALID_STYLES = ['family', 'quick', 'cozy', 'grill', 'vegetarian', 'kids', 'leftovers', 'other'];
|
||||||
|
|
||||||
|
function normalizeEnum(value, allowed, fallback = null) {
|
||||||
|
const normalized = String(value || '').trim().toLowerCase();
|
||||||
|
return allowed.includes(normalized) ? normalized : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTags(value) {
|
||||||
|
const tags = Array.isArray(value)
|
||||||
|
? value
|
||||||
|
: String(value || '').split(',');
|
||||||
|
return tags.map((tag) => String(tag || '').trim().toLowerCase()).filter(Boolean).slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
function loadRecipeWithIngredients(id) {
|
function loadRecipeWithIngredients(id) {
|
||||||
const recipe = db.get().prepare(`
|
const recipe = db.get().prepare(`
|
||||||
SELECT r.*, u.display_name AS creator_name, u.avatar_color AS creator_color
|
SELECT r.*, u.display_name AS creator_name, u.avatar_color AS creator_color
|
||||||
@@ -75,11 +91,16 @@ router.post('/', (req, res) => {
|
|||||||
const errors = collectErrors([vTitle, vNotes, vRecipeUrl]);
|
const errors = collectErrors([vTitle, vNotes, vRecipeUrl]);
|
||||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||||
|
|
||||||
|
const mealCategory = normalizeEnum(req.body.meal_category ?? req.body.mealCategory, VALID_MEAL_CATEGORIES, 'other');
|
||||||
|
const protein = normalizeEnum(req.body.protein, VALID_PROTEINS, 'mixed');
|
||||||
|
const style = normalizeEnum(req.body.style, VALID_STYLES, 'family');
|
||||||
|
const tagsJson = JSON.stringify(normalizeTags(req.body.tags ?? req.body.tags_json));
|
||||||
|
|
||||||
const recipeId = db.transaction(() => {
|
const recipeId = db.transaction(() => {
|
||||||
const result = db.get().prepare(`
|
const result = db.get().prepare(`
|
||||||
INSERT INTO recipes (title, notes, recipe_url, created_by)
|
INSERT INTO recipes (title, notes, recipe_url, meal_category, protein, style, tags_json, created_by)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(vTitle.value, vNotes.value, vRecipeUrl.value, req.session.userId);
|
`).run(vTitle.value, vNotes.value, vRecipeUrl.value, mealCategory, protein, style, tagsJson, req.session.userId);
|
||||||
|
|
||||||
const rid = Number(result.lastInsertRowid);
|
const rid = Number(result.lastInsertRowid);
|
||||||
const insertIng = db.get().prepare(`
|
const insertIng = db.get().prepare(`
|
||||||
@@ -122,12 +143,17 @@ router.put('/:id', (req, res) => {
|
|||||||
const errors = collectErrors([vTitle, vNotes, vRecipeUrl]);
|
const errors = collectErrors([vTitle, vNotes, vRecipeUrl]);
|
||||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||||
|
|
||||||
|
const mealCategory = normalizeEnum(req.body.meal_category ?? req.body.mealCategory, VALID_MEAL_CATEGORIES, existing.meal_category || 'other');
|
||||||
|
const protein = normalizeEnum(req.body.protein, VALID_PROTEINS, existing.protein || 'mixed');
|
||||||
|
const style = normalizeEnum(req.body.style, VALID_STYLES, existing.style || 'family');
|
||||||
|
const tagsJson = JSON.stringify(normalizeTags(req.body.tags ?? req.body.tags_json));
|
||||||
|
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
db.get().prepare(`
|
db.get().prepare(`
|
||||||
UPDATE recipes
|
UPDATE recipes
|
||||||
SET title = ?, notes = ?, recipe_url = ?
|
SET title = ?, notes = ?, recipe_url = ?, meal_category = ?, protein = ?, style = ?, tags_json = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(vTitle.value, vNotes.value, vRecipeUrl.value, id);
|
`).run(vTitle.value, vNotes.value, vRecipeUrl.value, mealCategory, protein, style, tagsJson, id);
|
||||||
|
|
||||||
db.get().prepare('DELETE FROM recipe_ingredients WHERE recipe_id = ?').run(id);
|
db.get().prepare('DELETE FROM recipe_ingredients WHERE recipe_id = ?').run(id);
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,24 @@ async function openMealPlanStudioFromAssist(page, diagnostics, prompt = 'Lav en
|
|||||||
return studioHost;
|
return studioHost;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readStudioPlan(page) {
|
||||||
|
return page.evaluate(() => JSON.parse(localStorage.getItem('oikos-meal-plan-studio-v1') || 'null'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertStructuredStudioPlan(plan) {
|
||||||
|
expect(plan?.slots, 'Studio plan should exist in localStorage for structural assertions').toHaveLength(7);
|
||||||
|
const categories = plan.slots.map((slot) => slot.meal_category || slot.variation?.category || 'other');
|
||||||
|
expect(categories.every((category) => category && category !== 'other'), 'all Studio slots should carry structured categories').toBeTruthy();
|
||||||
|
for (let i = 1; i < categories.length; i += 1) {
|
||||||
|
expect(categories[i], `planner should not repeat category on consecutive days (${i - 1}/${i})`).not.toBe(categories[i - 1]);
|
||||||
|
}
|
||||||
|
const counts = categories.reduce((acc, category) => ({ ...acc, [category]: (acc[category] || 0) + 1 }), {});
|
||||||
|
expect(Math.max(...Object.values(counts)), `planner should not overuse one category: ${JSON.stringify(counts)}`).toBeLessThanOrEqual(2);
|
||||||
|
const leftovers = plan.slots.filter((slot) => (slot.meal_category || slot.variation?.category) === 'leftovers' || slot.leftover_from_meal_id);
|
||||||
|
expect(leftovers.length, 'leftovers should be represented as a dedicated structured option when meal history exists').toBeGreaterThanOrEqual(1);
|
||||||
|
expect(leftovers.every((slot) => slot.leftover_from_meal_id || slot.context?.leftoverSource?.id), 'leftover slots should link to a source dish id').toBeTruthy();
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteMeals(page, mealIds) {
|
async function deleteMeals(page, mealIds) {
|
||||||
if (!mealIds.length) return [];
|
if (!mealIds.length) return [];
|
||||||
return page.evaluate(async (ids) => {
|
return page.evaluate(async (ids) => {
|
||||||
@@ -147,6 +165,8 @@ test.describe('Oikos Kitchen + Assist meal-planning flow', () => {
|
|||||||
|
|
||||||
await openMealPlanStudioFromAssist(page, diagnostics);
|
await openMealPlanStudioFromAssist(page, diagnostics);
|
||||||
|
|
||||||
|
assertStructuredStudioPlan(await readStudioPlan(page));
|
||||||
|
|
||||||
expect(diagnostics.pageErrors, 'No browser page errors during Kitchen/Assist flow').toEqual([]);
|
expect(diagnostics.pageErrors, 'No browser page errors during Kitchen/Assist flow').toEqual([]);
|
||||||
await attachDiagnostics(testInfo, diagnostics);
|
await attachDiagnostics(testInfo, diagnostics);
|
||||||
});
|
});
|
||||||
@@ -184,6 +204,8 @@ test.describe('Oikos Kitchen + Assist meal-planning flow', () => {
|
|||||||
const editPayload = await editResponse.json();
|
const editPayload = await editResponse.json();
|
||||||
expect(editPayload.source, 'edit feedback should flow through the native meal-planning API').toBe('oikos-native-api');
|
expect(editPayload.source, 'edit feedback should flow through the native meal-planning API').toBe('oikos-native-api');
|
||||||
|
|
||||||
|
assertStructuredStudioPlan(await readStudioPlan(page));
|
||||||
|
|
||||||
const actionResponsePromise = page.waitForResponse((res) => res.url().includes('/ai/api/action') && res.request().method() === 'POST');
|
const actionResponsePromise = page.waitForResponse((res) => res.url().includes('/ai/api/action') && res.request().method() === 'POST');
|
||||||
await studioHost.locator('[data-assist-studio-confirm]').click();
|
await studioHost.locator('[data-assist-studio-confirm]').click();
|
||||||
const actionResponse = await actionResponsePromise;
|
const actionResponse = await actionResponsePromise;
|
||||||
@@ -199,6 +221,48 @@ test.describe('Oikos Kitchen + Assist meal-planning flow', () => {
|
|||||||
const mealsInOikos = await listMealsById(page, createdMealIds, createdMeals.map((meal) => meal?.date));
|
const mealsInOikos = await listMealsById(page, createdMealIds, createdMeals.map((meal) => meal?.date));
|
||||||
expect(mealsInOikos, 'created meals should be readable from the native Oikos meals API').toHaveLength(7);
|
expect(mealsInOikos, 'created meals should be readable from the native Oikos meals API').toHaveLength(7);
|
||||||
expect(mealsInOikos.some((meal) => String(meal.title || '').includes('[E2E cleanup]'))).toBeTruthy();
|
expect(mealsInOikos.some((meal) => String(meal.title || '').includes('[E2E cleanup]'))).toBeTruthy();
|
||||||
|
expect(mealsInOikos.every((meal) => meal.meal_category), 'confirmed meals should preserve structured meal categories').toBeTruthy();
|
||||||
|
const leftoverMeal = mealsInOikos.find((meal) => meal.meal_category === 'leftovers');
|
||||||
|
expect(leftoverMeal?.leftover_from_meal_id, 'confirmed leftover meal should link to a source dish').toBeTruthy();
|
||||||
|
|
||||||
|
const favoriteCandidate = mealsInOikos.find((meal) => meal.recipe_id);
|
||||||
|
if (favoriteCandidate) {
|
||||||
|
await page.goto(`/meals?week=${favoriteCandidate.date}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await dismissOnboardingIfPresent(page);
|
||||||
|
const candidateCard = page.locator('.meal-card').filter({ hasText: favoriteCandidate.title }).first();
|
||||||
|
if (await candidateCard.count()) {
|
||||||
|
await candidateCard.click();
|
||||||
|
} else {
|
||||||
|
await page.locator('.meal-card').first().click();
|
||||||
|
}
|
||||||
|
await expect(page.locator('#modal-recipe-pref-member')).toBeVisible();
|
||||||
|
let modalRecipeId = Number(await page.locator('#modal-recipe-id').inputValue());
|
||||||
|
if (!modalRecipeId) {
|
||||||
|
await page.locator('#modal-recipe-id').selectOption(String(favoriteCandidate.recipe_id));
|
||||||
|
modalRecipeId = Number(await page.locator('#modal-recipe-id').inputValue());
|
||||||
|
}
|
||||||
|
expect(modalRecipeId, 'favorite flow should originate from a meal modal with a saved recipe').toBeTruthy();
|
||||||
|
const memberValue = await page.locator('#modal-recipe-pref-member option').nth(1).getAttribute('value');
|
||||||
|
if (memberValue) {
|
||||||
|
await page.locator('#modal-recipe-pref-member').selectOption(memberValue);
|
||||||
|
const prefResponsePromise = page.waitForResponse((res) => res.url().includes(`/api/v1/meal-planning/recipe-signals/${modalRecipeId}`) && res.request().method() === 'PUT');
|
||||||
|
await page.locator('[data-meal-recipe-pref="favorite"]').click();
|
||||||
|
const prefResponse = await prefResponsePromise;
|
||||||
|
expect(prefResponse.ok(), 'meal modal favorite action should write native recipe signals').toBeTruthy();
|
||||||
|
const members = await page.evaluate(async () => (await (await fetch('/api/v1/family/members', { credentials: 'same-origin' })).json()).data || []);
|
||||||
|
const member = members.find((item) => String(item.id) === String(memberValue));
|
||||||
|
expect(Number(member?.favorite_meal_count || 0), 'family profile card data should reflect favorite meal count').toBeGreaterThanOrEqual(1);
|
||||||
|
diagnostics.favoriteSignalCleanup = await page.evaluate(async ({ recipeId, userId }) => {
|
||||||
|
const csrf = document.cookie.split(';').map((c) => c.trim()).find((c) => c.startsWith('csrf-token='))?.slice('csrf-token='.length) || '';
|
||||||
|
const res = await fetch(`/api/v1/meal-planning/recipe-signals/${recipeId}`, {
|
||||||
|
method: 'PUT', credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': decodeURIComponent(csrf) },
|
||||||
|
body: JSON.stringify({ user_id: userId, preference: 'neutral', can_cook: false, can_help_cook: false, will_eat_modified: false, adult_only: false }),
|
||||||
|
});
|
||||||
|
return { ok: res.ok, status: res.status };
|
||||||
|
}, { recipeId: modalRecipeId, userId: memberValue });
|
||||||
|
}
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
const cleanup = await deleteMeals(page, createdMealIds).catch((err) => [{ error: err.message }]);
|
const cleanup = await deleteMeals(page, createdMealIds).catch((err) => [{ error: err.message }]);
|
||||||
diagnostics.cleanup = cleanup;
|
diagnostics.cleanup = cleanup;
|
||||||
|
|||||||
Reference in New Issue
Block a user