feat: structure meal planning taxonomy and favorites

This commit is contained in:
OpenClaw Bot
2026-05-12 17:15:31 +02:00
parent cef366cce4
commit 58a76ee02d
9 changed files with 442 additions and 20 deletions
+137 -6
View File
@@ -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]) => `<option value="${esc(value)}" ${value === selected ? 'selected' : ''}>${esc(label)}</option>`).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 `
<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}"
role="button" tabindex="0">
<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>` : ''}
${cookName ? `<span class="meal-card__cook"><i data-lucide="chef-hat" style="width:13px;height:13px;" aria-hidden="true"></i>${esc(cookName)}</span>` : ''}
</div>` : ''}
@@ -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) => `<option value="${member.id}" ${selectedCookId === String(member.id) ? 'selected' : ''}>${esc(member.display_name)}</option>`),
].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 `
<div class="modal-grid modal-grid--2">
<div class="form-group">
@@ -803,6 +896,40 @@ function buildModalContent({ mode, date, mealType, meal }) {
<select class="form-input" id="modal-recipe-id">${recipeOptions}</select>
</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="form-group">
<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 recipe_url = overlay.querySelector('#modal-recipe-url').value.trim() || 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 cook_user_id = cookSelect?.value ? Number(cookSelect.value) : null;
@@ -909,7 +1040,7 @@ async function saveModal(overlay) {
try {
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') {
const res = await api.post('/meals', { ...mealPayload, ingredients });
+126 -3
View File
@@ -8,14 +8,28 @@ import { t } from '/i18n.js';
import { openModal as openSharedModal, closeModal as closeSharedModal } from '/components/modal.js';
import { DEFAULT_CATEGORY_NAME, categoryLabel } from '/utils/shopping-categories.js';
import { renderKitchenTabsBar } from '/utils/kitchen-tabs.js';
import { esc } from '/utils/html.js';
let _container = null;
const state = {
recipes: [],
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() {
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) {
_container = container;
@@ -75,7 +128,7 @@ export async function render(container) {
if (window.lucide) window.lucide.createIcons();
await Promise.all([loadRecipes(), loadCategories()]);
await Promise.all([loadRecipes(), loadCategories(), loadFamilyMembers(), loadRecipeSignals()]);
renderRecipeList();
addBtn.addEventListener('click', () => openRecipeModal('create'));
@@ -107,6 +160,13 @@ export async function render(container) {
if (actionBtn.dataset.action === 'add-to-meals') {
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);
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) {
const notes = document.createElement('p');
notes.className = 'recipe-card__notes';
@@ -220,6 +301,26 @@ function renderRecipeList() {
actions.append(addToMeals, edit, duplicate, del);
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);
}
}
@@ -298,6 +399,24 @@ function openRecipeModal(mode, recipe = null) {
<label class="form-label" for="recipe-url">${t('recipes.urlLabel')}</label>
<input id="recipe-url" class="form-input" type="url" placeholder="${t('recipes.urlPlaceholder')}">
</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">
<label class="form-label">${t('recipes.ingredientsLabel')}</label>
<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 notes = panel.querySelector('#recipe-notes')?.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) {
window.oikos?.showToast(t('recipes.titleRequired'), 'error');
@@ -366,10 +489,10 @@ async function saveRecipe(panel, mode, recipe) {
try {
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);
} 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);
if (idx >= 0) state.recipes[idx] = res.data;
}
+2
View File
@@ -2334,6 +2334,8 @@ function memberHtml(u) {
u.phone ? t('settings.memberPhoneMeta', { value: u.phone }) : '',
u.email || '',
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(' · ');
return `
<li class="settings-member" data-id="${u.id}">
+3 -1
View File
@@ -31,7 +31,9 @@ const USER_PUBLIC_COLUMNS = `
created_at,
(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 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
`;
// --------------------------------------------------------
+28
View File
@@ -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);
`);
},
},
];
/**
+2
View File
@@ -22,6 +22,8 @@ router.get('/members', (req, res) => {
c.phone,
c.email,
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
FROM users u
LEFT JOIN contacts c ON c.family_user_id = u.id
+49 -5
View File
@@ -15,6 +15,9 @@ const log = createLogger('Meals');
const router = express.Router();
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
@@ -75,6 +78,21 @@ function currentUserId(req) {
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) {
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
* 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 }
*/
router.post('/', (req, res) => {
@@ -238,12 +256,19 @@ router.post('/', (req, res) => {
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 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 errors = collectErrors([vDate, vType, vTitle, vNotes, vRecipeUrl, vRecipeId, vSourcePlanId]);
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 (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) {
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 });
@@ -251,9 +276,9 @@ router.post('/', (req, res) => {
const meal = db.transaction(() => {
const result = db.get().prepare(`
INSERT INTO meals (date, meal_type, title, notes, recipe_url, recipe_id, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(vDate.value, vType.value, vTitle.value, vNotes.value, vRecipeUrl.value, vRecipeId.value, currentUserId(req));
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).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;
@@ -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 }));
const cookUserRaw = Object.hasOwn(req.body, 'cook_user_id') ? req.body.cook_user_id : req.body.cookUserId;
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 errors = collectErrors([...checks, vSourcePlanId]);
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 (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 });
}
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(`
UPDATE meals
SET date = COALESCE(?, date),
@@ -333,7 +367,12 @@ router.put('/:id', (req, res) => {
title = COALESCE(?, title),
notes = ?,
recipe_url = ?,
recipe_id = ?
recipe_id = ?,
meal_category = ?,
protein = ?,
style = ?,
leftover_from_meal_id = ?,
source_plan_id = ?
WHERE id = ?
`).run(
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.recipe_url !== undefined ? (req.body.recipe_url || null) : meal.recipe_url,
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
);
+31 -5
View File
@@ -12,6 +12,22 @@ import { str, num, collectErrors, MAX_TITLE, MAX_TEXT, MAX_SHORT } from '../midd
const log = createLogger('Recipes');
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) {
const recipe = db.get().prepare(`
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]);
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 result = db.get().prepare(`
INSERT INTO recipes (title, notes, recipe_url, created_by)
VALUES (?, ?, ?, ?)
`).run(vTitle.value, vNotes.value, vRecipeUrl.value, req.session.userId);
INSERT INTO recipes (title, notes, recipe_url, meal_category, protein, style, tags_json, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(vTitle.value, vNotes.value, vRecipeUrl.value, mealCategory, protein, style, tagsJson, req.session.userId);
const rid = Number(result.lastInsertRowid);
const insertIng = db.get().prepare(`
@@ -122,12 +143,17 @@ router.put('/:id', (req, res) => {
const errors = collectErrors([vTitle, vNotes, vRecipeUrl]);
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.get().prepare(`
UPDATE recipes
SET title = ?, notes = ?, recipe_url = ?
SET title = ?, notes = ?, recipe_url = ?, meal_category = ?, protein = ?, style = ?, tags_json = ?
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);
@@ -61,6 +61,24 @@ async function openMealPlanStudioFromAssist(page, diagnostics, prompt = 'Lav en
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) {
if (!mealIds.length) return [];
return page.evaluate(async (ids) => {
@@ -147,6 +165,8 @@ test.describe('Oikos Kitchen + Assist meal-planning flow', () => {
await openMealPlanStudioFromAssist(page, diagnostics);
assertStructuredStudioPlan(await readStudioPlan(page));
expect(diagnostics.pageErrors, 'No browser page errors during Kitchen/Assist flow').toEqual([]);
await attachDiagnostics(testInfo, diagnostics);
});
@@ -184,6 +204,8 @@ test.describe('Oikos Kitchen + Assist meal-planning flow', () => {
const editPayload = await editResponse.json();
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');
await studioHost.locator('[data-assist-studio-confirm]').click();
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));
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.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 {
const cleanup = await deleteMeals(page, createdMealIds).catch((err) => [{ error: err.message }]);
diagnostics.cleanup = cleanup;