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 }) {
+
+
+
+
+
+
+
+
+
+
+
+
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 ? `
` : ''}
+ ${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)
* -------------------------------------------------------- */