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
+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;
}