feat: surface smart dinner fit panel

This commit is contained in:
OpenClaw Bot
2026-05-23 17:08:19 +02:00
parent 3ac82e65fe
commit 29a8b2993c
3 changed files with 359 additions and 0 deletions
+1
View File
@@ -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 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 `/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 `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 ## Remaining Combined Plan
+197
View File
@@ -50,6 +50,12 @@ let state = {
categories: [], // Einkaufskategorien für Zutaten categories: [], // Einkaufskategorien für Zutaten
modal: null, modal: null,
visibleMealTypes: ['breakfast', 'lunch', 'dinner', 'snack'], 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) // Container-Referenz für Hilfsfunktionen (wird in render() gesetzt)
@@ -222,6 +228,47 @@ export async function render(container, { user }) {
<i data-lucide="chevron-right" aria-hidden="true"></i> <i data-lucide="chevron-right" aria-hidden="true"></i>
</button> </button>
</div> </div>
<div class="meal-fit-panel" id="meal-fit-panel">
<div class="meal-fit-panel__header">
<div>
<p class="meal-fit-panel__eyebrow">Family logistics</p>
<h2 class="meal-fit-panel__title">Smart dinner fit</h2>
<p class="meal-fit-panel__subtitle">Ranks saved recipes against the actual day: pressure, energy, guests, leftovers and grocery burden.</p>
</div>
<button class="btn btn--primary" id="meal-fit-run" type="button">Suggest dinners</button>
</div>
<div class="meal-fit-panel__controls">
<label class="meal-fit-control">Day
<select class="form-input" id="meal-fit-date"></select>
</label>
<label class="meal-fit-control">Pressure
<select class="form-input" id="meal-fit-busyness">
<option value="normal">Normal</option>
<option value="high">Busy / late activity</option>
<option value="low">Calm</option>
</select>
</label>
<label class="meal-fit-control">Energy
<select class="form-input" id="meal-fit-energy">
<option value="normal">Normal</option>
<option value="low">Low</option>
<option value="high">High</option>
</select>
</label>
<label class="meal-fit-control">Dinner window
<select class="form-input" id="meal-fit-window">
<option value="25">25 min</option>
<option value="45" selected>45 min</option>
<option value="90">90 min</option>
</select>
</label>
<label class="meal-fit-check"><input type="checkbox" id="meal-fit-guests"> Guests</label>
<label class="meal-fit-check"><input type="checkbox" id="meal-fit-solo"> Solo parent</label>
</div>
<div class="meal-fit-results" id="meal-fit-results" aria-live="polite">
<p class="meal-fit-empty">Choose a day context and run suggestions. If the ranking does not change when the day changes, we have failed.</p>
</div>
</div>
<div class="week-grid" id="week-grid"> <div class="week-grid" id="week-grid">
<div style="margin:auto;padding:2rem;text-align:center;color:var(--color-text-disabled)">${t('meals.loadingIndicator')}</div> <div style="margin:auto;padding:2rem;text-align:center;color:var(--color-text-disabled)">${t('meals.loadingIndicator')}</div>
</div> </div>
@@ -241,7 +288,9 @@ export async function render(container, { user }) {
await Promise.all([loadWeek(monday), loadLists(), loadPreferences(), loadCategories(), loadRecipes(), loadFamilyMembers(), loadRecipeSignals()]); await Promise.all([loadWeek(monday), loadLists(), loadPreferences(), loadCategories(), loadRecipes(), loadFamilyMembers(), loadRecipeSignals()]);
renderWeekGrid(); renderWeekGrid();
renderMealFitPanel();
wireNav(); wireNav();
wireMealFitPanel();
const selectedRecipeId = Number(params.get('recipe')); const selectedRecipeId = Number(params.get('recipe'));
if (selectedRecipeId) { 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) => `<option value="${date}" ${date === state.planner.selectedDate ? 'selected' : ''}>${esc(DAY_NAMES()[idx])} · ${esc(formatDayDate(date))}</option>`).join('');
if (state.planner.loading) {
results.innerHTML = '<p class="meal-fit-empty">Scoring dinners against family reality…</p>';
return;
}
if (!state.planner.suggestions.length) {
results.innerHTML = '<p class="meal-fit-empty">Choose a day context and run suggestions. If the ranking does not change when the day changes, we have failed.</p>';
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) => `<li>${esc(reason)}</li>`).join('');
const warnings = (suggestion.warnings || []).slice(0, 2).map((warning) => `<li>${esc(warning)}</li>`).join('');
const grocery = suggestion.groceryDelta ? `${suggestion.groceryDelta.newItems} new grocery item${suggestion.groceryDelta.newItems === 1 ? '' : 's'}` : '';
return `
<article class="meal-fit-card">
<div class="meal-fit-card__rank">#${idx + 1}</div>
<div class="meal-fit-card__body">
<div class="meal-fit-card__topline">
<h3>${esc(suggestion.mealName)}</h3>
<span class="meal-fit-card__score">Fit ${esc(String(suggestion.score))}</span>
</div>
<div class="meal-fit-card__chips">
${(suggestion.fitLabels || []).slice(0, 4).map((label) => `<span>${esc(label.replaceAll('-', ' '))}</span>`).join('')}
${grocery ? `<span>${esc(grocery)}</span>` : ''}
</div>
${reasons ? `<ul class="meal-fit-card__reasons">${reasons}</ul>` : ''}
${warnings ? `<ul class="meal-fit-card__warnings">${warnings}</ul>` : ''}
</div>
</article>
`;
}
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 // Event-Delegation
// -------------------------------------------------------- // --------------------------------------------------------
@@ -370,11 +564,13 @@ function wireNav() {
_container.querySelector('#week-prev')?.addEventListener('click', async () => { _container.querySelector('#week-prev')?.addEventListener('click', async () => {
await loadWeek(addDays(state.currentWeek, -7)); await loadWeek(addDays(state.currentWeek, -7));
renderWeekGrid(); renderWeekGrid();
renderMealFitPanel();
}); });
_container.querySelector('#week-next')?.addEventListener('click', async () => { _container.querySelector('#week-next')?.addEventListener('click', async () => {
await loadWeek(addDays(state.currentWeek, 7)); await loadWeek(addDays(state.currentWeek, 7));
renderWeekGrid(); renderWeekGrid();
renderMealFitPanel();
}); });
_container.querySelector('#week-today')?.addEventListener('click', async () => { _container.querySelector('#week-today')?.addEventListener('click', async () => {
@@ -382,6 +578,7 @@ function wireNav() {
if (monday === state.currentWeek) return; if (monday === state.currentWeek) return;
await loadWeek(monday); await loadWeek(monday);
renderWeekGrid(); renderWeekGrid();
renderMealFitPanel();
}); });
} }
+161
View File
@@ -62,6 +62,167 @@
white-space: nowrap; 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) * Wochengitter (scroll horizontal auf Mobil)
* -------------------------------------------------------- */ * -------------------------------------------------------- */