From 29a8b2993c647046852f2f046b8b91f855772b3c Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Sat, 23 May 2026 17:08:19 +0200 Subject: [PATCH] feat: surface smart dinner fit panel --- docs/MEAL_PLANNING_ROADMAP.md | 1 + public/pages/meals.js | 197 ++++++++++++++++++++++++++++++++++ public/styles/meals.css | 161 +++++++++++++++++++++++++++ 3 files changed, 359 insertions(+) diff --git a/docs/MEAL_PLANNING_ROADMAP.md b/docs/MEAL_PLANNING_ROADMAP.md index ec1fddb..98735d8 100644 --- a/docs/MEAL_PLANNING_ROADMAP.md +++ b/docs/MEAL_PLANNING_ROADMAP.md @@ -70,6 +70,7 @@ Meal planning should be a native Oikos kitchen workflow, not a chatbot shortcut. - [x] Added deterministic meal-fit service for the next planning slice: day context, energy, guests, cleanup, interruption tolerance, concrete inventory/leftover use, preferences/allergies, recency, and grocery delta all affect ranking before AI gets involved. - [x] Added `/api/v1/meal-planning/suggestions` as a thin native API boundary returning ranked suggestions plus optional generated grocery output. - [x] Added `test:meal-fit` acceptance coverage proving busy/low-energy days, guest days, allergy blocks, dislike/repetition, concrete inventory matching, and grocery dedupe change outputs materially. +- [x] Added a first native Meals-page “Smart dinner fit” panel that lets the family change day pressure, energy, dinner window, guests, and solo-parent context, then see grounded ranked suggestions from saved recipes. ## Remaining Combined Plan diff --git a/public/pages/meals.js b/public/pages/meals.js index 94ef31f..8f3bd74 100644 --- a/public/pages/meals.js +++ b/public/pages/meals.js @@ -50,6 +50,12 @@ let state = { categories: [], // Einkaufskategorien für Zutaten modal: null, visibleMealTypes: ['breakfast', 'lunch', 'dinner', 'snack'], + planner: { + suggestions: [], + selectedDate: null, + context: { busyness: 'normal', energy: 'normal', dinnerWindowMinutes: 45, guests: false, soloParent: false }, + loading: false, + }, }; // Container-Referenz für Hilfsfunktionen (wird in render() gesetzt) @@ -222,6 +228,47 @@ export async function render(container, { user }) { +
+
+
+

Family logistics

+

Smart dinner fit

+

Ranks saved recipes against the actual day: pressure, energy, guests, leftovers and grocery burden.

+
+ +
+
+ + + + + + +
+
+

Choose a day context and run suggestions. If the ranking does not change when the day changes, we have failed.

+
+
${t('meals.loadingIndicator')}
@@ -241,7 +288,9 @@ export async function render(container, { user }) { await Promise.all([loadWeek(monday), loadLists(), loadPreferences(), loadCategories(), loadRecipes(), loadFamilyMembers(), loadRecipeSignals()]); renderWeekGrid(); + renderMealFitPanel(); wireNav(); + wireMealFitPanel(); const selectedRecipeId = Number(params.get('recipe')); if (selectedRecipeId) { @@ -362,6 +411,151 @@ function renderSlot(date, type, mealsForDay) { `; } +// -------------------------------------------------------- +// Smart dinner fit panel +// -------------------------------------------------------- + +function recipeToPlannerMeal(recipe) { + const tags = parseRecipeTags(recipe.tags_json); + const style = recipe.style || 'family'; + const isQuick = style === 'quick' || tags.includes('quick') || tags.includes('hurtig'); + const isLeftovers = style === 'leftovers' || recipe.meal_category === 'leftovers'; + const isCozy = style === 'cozy' || tags.includes('cozy') || tags.includes('hygge'); + const guestFit = Boolean(recipe.guest_fit || recipe.batch_friendly || tags.includes('guest') || tags.includes('guests') || tags.includes('batch')); + return { + id: recipe.id, + name: recipe.title, + ingredients: recipe.ingredients || [], + activeMinutes: Number(recipe.active_minutes || recipe.activeMinutes || (isQuick ? 15 : isCozy ? 45 : 30)), + totalMinutes: Number(recipe.total_minutes || recipe.totalMinutes || (isQuick ? 20 : isCozy ? 75 : 45)), + effort: isQuick || isLeftovers ? 'easy' : isCozy ? 'project' : 'normal', + cleanup: tags.includes('one-pot') || tags.includes('traybake') || isLeftovers ? 'low' : isCozy ? 'high' : 'medium', + interruptionTolerance: isQuick || isLeftovers || tags.includes('slow') || tags.includes('traybake') ? 'high' : 'medium', + kidFit: recipe.style === 'kids' || tags.includes('kids') || tags.includes('børnevenlig') ? 'safe' : 'mixed', + guestFit, + batchFriendly: guestFit || tags.includes('batch'), + meal_category: recipe.meal_category, + protein: recipe.protein, + style, + tags, + }; +} + +function parseRecipeTags(value) { + if (Array.isArray(value)) return value.map((tag) => String(tag).toLowerCase()); + if (!value) return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed.map((tag) => String(tag).toLowerCase()) : []; + } catch { + return String(value).split(',').map((tag) => tag.trim().toLowerCase()).filter(Boolean); + } +} + +function plannerInventory() { + return state.meals + .filter((meal) => meal.meal_type === 'dinner') + .slice(-8) + .map((meal) => ({ name: meal.title, portions: 1, sourceMealId: meal.id })); +} + +function plannerPreferences() { + return state.recipeSignals + .map((signal) => { + const recipe = state.recipes.find((candidate) => Number(candidate.id) === Number(signal.recipe_id)); + if (!recipe || !['like', 'favorite', 'dislike'].includes(signal.preference)) return null; + return { type: signal.preference === 'favorite' ? 'like' : signal.preference, target: recipe.title, strength: signal.preference === 'favorite' ? 'high' : 'medium' }; + }) + .filter(Boolean); +} + +function selectedPlannerContext() { + const busyness = _container.querySelector('#meal-fit-busyness')?.value || 'normal'; + const energy = _container.querySelector('#meal-fit-energy')?.value || 'normal'; + const dinnerWindowMinutes = Number(_container.querySelector('#meal-fit-window')?.value || 45); + const guests = Boolean(_container.querySelector('#meal-fit-guests')?.checked); + const soloParent = Boolean(_container.querySelector('#meal-fit-solo')?.checked); + const labels = [ + busyness === 'high' ? 'late_activity' : null, + energy === 'low' ? 'low_energy' : null, + soloParent ? 'solo_parent' : null, + ].filter(Boolean); + return { busyness, energy, dinnerWindowMinutes, guests, soloParent, labels }; +} + +function renderMealFitPanel() { + if (!_container) return; + const dateSelect = _container.querySelector('#meal-fit-date'); + const results = _container.querySelector('#meal-fit-results'); + if (!dateSelect || !results) return; + const days = Array.from({ length: 7 }, (_, i) => addDays(state.currentWeek, i)); + if (!state.planner.selectedDate || !days.includes(state.planner.selectedDate)) state.planner.selectedDate = days[0]; + dateSelect.innerHTML = days.map((date, idx) => ``).join(''); + + if (state.planner.loading) { + results.innerHTML = '

Scoring dinners against family reality…

'; + return; + } + if (!state.planner.suggestions.length) { + results.innerHTML = '

Choose a day context and run suggestions. If the ranking does not change when the day changes, we have failed.

'; + return; + } + results.innerHTML = state.planner.suggestions.slice(0, 3).map(renderMealFitSuggestion).join(''); + if (window.lucide) lucide.createIcons(); +} + +function renderMealFitSuggestion(suggestion, idx) { + const reasons = (suggestion.reasons || []).slice(0, 3).map((reason) => `
  • ${esc(reason)}
  • `).join(''); + const warnings = (suggestion.warnings || []).slice(0, 2).map((warning) => `
  • ${esc(warning)}
  • `).join(''); + const grocery = suggestion.groceryDelta ? `${suggestion.groceryDelta.newItems} new grocery item${suggestion.groceryDelta.newItems === 1 ? '' : 's'}` : ''; + return ` +
    +
    #${idx + 1}
    +
    +
    +

    ${esc(suggestion.mealName)}

    + Fit ${esc(String(suggestion.score))} +
    +
    + ${(suggestion.fitLabels || []).slice(0, 4).map((label) => `${esc(label.replaceAll('-', ' '))}`).join('')} + ${grocery ? `${esc(grocery)}` : ''} +
    + ${reasons ? `
      ${reasons}
    ` : ''} + ${warnings ? `
      ${warnings}
    ` : ''} +
    +
    + `; +} + +function wireMealFitPanel() { + const runBtn = _container.querySelector('#meal-fit-run'); + if (!runBtn) return; + _container.querySelector('#meal-fit-date')?.addEventListener('change', (event) => { state.planner.selectedDate = event.target.value; }); + runBtn.addEventListener('click', async () => { + state.planner.selectedDate = _container.querySelector('#meal-fit-date')?.value || state.planner.selectedDate; + state.planner.context = selectedPlannerContext(); + state.planner.loading = true; + renderMealFitPanel(); + try { + const res = await api.post('/meal-planning/suggestions', { + meals: state.recipes.map(recipeToPlannerMeal), + dayContext: state.planner.context, + preferences: plannerPreferences(), + inventory: plannerInventory(), + recentMeals: state.meals.filter((meal) => meal.date < state.planner.selectedDate).slice(-6), + pantryStaples: ['salt', 'pepper', 'oil', 'olive oil', 'water'], + }); + state.planner.suggestions = res.data.suggestions || []; + } catch (err) { + state.planner.suggestions = []; + window.oikos?.showToast(err.data?.error ?? 'Meal suggestions failed', 'error'); + } finally { + state.planner.loading = false; + renderMealFitPanel(); + } + }); +} + // -------------------------------------------------------- // Event-Delegation // -------------------------------------------------------- @@ -370,11 +564,13 @@ function wireNav() { _container.querySelector('#week-prev')?.addEventListener('click', async () => { await loadWeek(addDays(state.currentWeek, -7)); renderWeekGrid(); + renderMealFitPanel(); }); _container.querySelector('#week-next')?.addEventListener('click', async () => { await loadWeek(addDays(state.currentWeek, 7)); renderWeekGrid(); + renderMealFitPanel(); }); _container.querySelector('#week-today')?.addEventListener('click', async () => { @@ -382,6 +578,7 @@ function wireNav() { if (monday === state.currentWeek) return; await loadWeek(monday); renderWeekGrid(); + renderMealFitPanel(); }); } diff --git a/public/styles/meals.css b/public/styles/meals.css index 4b90f4c..037279c 100644 --- a/public/styles/meals.css +++ b/public/styles/meals.css @@ -62,6 +62,167 @@ white-space: nowrap; } +/* -------------------------------------------------------- + * Smart dinner fit panel + * -------------------------------------------------------- */ +.meal-fit-panel { + margin: var(--space-3) var(--space-4); + padding: var(--space-4); + border: 1px solid color-mix(in srgb, var(--module-accent) 28%, var(--color-border)); + border-radius: var(--radius-lg); + background: + radial-gradient(circle at top left, color-mix(in srgb, var(--module-accent) 16%, transparent), transparent 40%), + var(--color-surface); + box-shadow: var(--shadow-sm); + flex-shrink: 0; +} + +.meal-fit-panel__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-3); +} + +.meal-fit-panel__eyebrow { + margin: 0 0 var(--space-1); + color: var(--module-accent); + font-size: var(--text-xs); + font-weight: var(--font-weight-bold); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.meal-fit-panel__title { + margin: 0; + color: var(--color-text-primary); + font-size: var(--text-lg); +} + +.meal-fit-panel__subtitle { + margin: var(--space-1) 0 0; + max-width: 68ch; + color: var(--color-text-secondary); + font-size: var(--text-sm); + line-height: 1.45; +} + +.meal-fit-panel__controls { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + align-items: end; + gap: var(--space-3); + margin-top: var(--space-3); +} + +.meal-fit-control, +.meal-fit-check { + color: var(--color-text-secondary); + font-size: var(--text-xs); + font-weight: var(--font-weight-semibold); +} + +.meal-fit-control { + display: grid; + gap: var(--space-1); +} + +.meal-fit-check { + display: inline-flex; + align-items: center; + gap: var(--space-2); + min-height: var(--target-lg); +} + +.meal-fit-results { + display: grid; + gap: var(--space-2); + margin-top: var(--space-3); +} + +.meal-fit-empty { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-sm); +} + +.meal-fit-card { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--color-surface) 84%, var(--module-accent) 6%); +} + +.meal-fit-card__rank { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: var(--radius-full); + background: var(--module-accent); + color: var(--color-text-inverse, #fff); + font-weight: var(--font-weight-bold); +} + +.meal-fit-card__topline { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: var(--space-2); +} + +.meal-fit-card__topline h3 { + margin: 0; + color: var(--color-text-primary); + font-size: var(--text-base); +} + +.meal-fit-card__score { + color: var(--color-text-secondary); + font-size: var(--text-xs); + font-weight: var(--font-weight-semibold); +} + +.meal-fit-card__chips { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + margin-top: var(--space-2); +} + +.meal-fit-card__chips span { + padding: 2px var(--space-2); + border-radius: var(--radius-full); + background: var(--color-accent-light); + color: var(--color-accent); + font-size: var(--text-xs); + font-weight: var(--font-weight-semibold); +} + +.meal-fit-card__reasons, +.meal-fit-card__warnings { + margin: var(--space-2) 0 0; + padding-left: var(--space-4); + font-size: var(--text-sm); + line-height: 1.45; +} + +.meal-fit-card__reasons { color: var(--color-text-secondary); } +.meal-fit-card__warnings { color: var(--color-warning, #b45309); } + +@media (max-width: 720px) { + .meal-fit-panel__header { + flex-direction: column; + } + + .meal-fit-panel__header .btn { + width: 100%; + } +} + /* -------------------------------------------------------- * Wochengitter (scroll horizontal auf Mobil) * -------------------------------------------------------- */