Compare commits
2 Commits
0bbbc1d154
...
29a8b2993c
| Author | SHA1 | Date | |
|---|---|---|---|
| 29a8b2993c | |||
| 3ac82e65fe |
@@ -65,6 +65,13 @@ Meal planning should be a native Oikos kitchen workflow, not a chatbot shortcut.
|
||||
- [x] Structural E2E asserts Studio plans expose modulator labels and active signal types, so planning context is not hidden in opaque AI text.
|
||||
- [x] Playwright artifacts are opt-in to reduce credential leak risk.
|
||||
|
||||
### Context-aware family logistics engine
|
||||
|
||||
- [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
|
||||
|
||||
### P0 — Upstream/Core polish before PR
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"test:carddav": "node --experimental-sqlite test-carddav.js",
|
||||
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-multi-assignment.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs && npm run test:backup-scheduler && npm run test:caldav && npm run test:carddav",
|
||||
"test:meal-planning": "node --experimental-sqlite test-meal-planning.js",
|
||||
"test:meal-fit": "node test-meal-fit.js",
|
||||
"test:e2e:oikos-flow": "playwright test tests/e2e/oikos-kitchen-assist-flow.spec.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -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 }) {
|
||||
<i data-lucide="chevron-right" aria-hidden="true"></i>
|
||||
</button>
|
||||
</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 style="margin:auto;padding:2rem;text-align:center;color:var(--color-text-disabled)">${t('meals.loadingIndicator')}</div>
|
||||
</div>
|
||||
@@ -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) => `<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
|
||||
// --------------------------------------------------------
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
@@ -9,6 +9,7 @@ import crypto from 'node:crypto';
|
||||
import * as db from '../db.js';
|
||||
import { createLogger } from '../logger.js';
|
||||
import { str, num, date, oneOf, collectErrors, MAX_TITLE, MAX_TEXT, MAX_SHORT } from '../middleware/validate.js';
|
||||
import { generateGroceryList, scoreMealSuggestions } from '../services/meal-fit.js';
|
||||
|
||||
const log = createLogger('MealPlanning');
|
||||
const router = express.Router();
|
||||
@@ -254,6 +255,28 @@ router.put('/cook-assignments/:mealId', (req, res) => {
|
||||
} catch (err) { handleError(res, err, 'PUT /cook-assignments/:mealId'); }
|
||||
});
|
||||
|
||||
router.post('/suggestions', (req, res) => {
|
||||
try {
|
||||
const meals = Array.isArray(req.body?.meals) ? req.body.meals : [];
|
||||
const selectedMeals = Array.isArray(req.body?.selectedMeals) ? req.body.selectedMeals : [];
|
||||
const context = {
|
||||
meals,
|
||||
dayContext: req.body?.dayContext || req.body?.day_context || {},
|
||||
preferences: Array.isArray(req.body?.preferences) ? req.body.preferences : [],
|
||||
inventory: Array.isArray(req.body?.inventory) ? req.body.inventory : [],
|
||||
recentMeals: Array.isArray(req.body?.recentMeals) ? req.body.recentMeals : [],
|
||||
pantryStaples: Array.isArray(req.body?.pantryStaples) ? req.body.pantryStaples : [],
|
||||
today: req.body?.today,
|
||||
};
|
||||
if (!meals.length) return res.status(400).json({ error: 'At least one meal is required.', code: 400 });
|
||||
const suggestions = scoreMealSuggestions(context);
|
||||
const groceryList = selectedMeals.length
|
||||
? generateGroceryList(selectedMeals, { inventory: context.inventory, pantryStaples: context.pantryStaples })
|
||||
: null;
|
||||
res.json({ data: { suggestions, groceryList } });
|
||||
} catch (err) { handleError(res, err, 'POST /suggestions'); }
|
||||
});
|
||||
|
||||
router.get('/feedback', (req, res) => {
|
||||
try {
|
||||
const limit = Math.max(1, Math.min(200, Number(req.query.limit || 50)));
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Modul: Meal Fit Service
|
||||
* Zweck: Deterministic context-aware meal ranking and grocery deltas for family logistics.
|
||||
* Dependencies: none
|
||||
*/
|
||||
|
||||
const STOP_WORDS = new Set([
|
||||
'fresh', 'frozen', 'canned', 'can', 'jar', 'large', 'small', 'medium', 'chopped', 'diced',
|
||||
'sliced', 'grated', 'shredded', 'minced', 'whole', 'organic', 'low', 'fat', 'lean', 'extra',
|
||||
'of', 'with', 'and', 'the', 'a', 'an', 'to', 'for', 'optional', 'some', 'little',
|
||||
]);
|
||||
|
||||
const INGREDIENT_ALIASES = new Map([
|
||||
['cheddar cheese', 'cheddar'],
|
||||
['grated cheddar', 'cheddar'],
|
||||
['shredded cheddar', 'cheddar'],
|
||||
['chicken breast', 'chicken'],
|
||||
['chicken breasts', 'chicken'],
|
||||
['leftover chicken', 'chicken'],
|
||||
['minced beef', 'beef'],
|
||||
['ground beef', 'beef'],
|
||||
['beef mince', 'beef'],
|
||||
['bell pepper', 'pepper'],
|
||||
['bell peppers', 'pepper'],
|
||||
['tortilla wraps', 'tortillas'],
|
||||
['wraps', 'tortillas'],
|
||||
]);
|
||||
|
||||
const EFFORT_SCORE = { survival: 4, easy: 3, normal: 1, project: -4 };
|
||||
const CLEANUP_SCORE = { low: 3, medium: 0, high: -4 };
|
||||
const INTERRUPTION_SCORE = { high: 3, medium: 0, low: -4 };
|
||||
const KID_SCORE = { safe: 3, mixed: 0, risky: -4 };
|
||||
|
||||
function toArray(value) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
export function normalizeIngredientName(value) {
|
||||
const raw = String(value || '').toLowerCase().replace(/[^a-z0-9æøåäöüéèàçñ\s-]/gi, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (!raw) return '';
|
||||
if (INGREDIENT_ALIASES.has(raw)) return INGREDIENT_ALIASES.get(raw);
|
||||
const singularish = raw.replace(/ies$/, 'y').replace(/oes$/, 'o').replace(/s$/, '');
|
||||
if (INGREDIENT_ALIASES.has(singularish)) return INGREDIENT_ALIASES.get(singularish);
|
||||
return singularish.split(' ').filter((part) => part && !STOP_WORDS.has(part)).join(' ') || singularish;
|
||||
}
|
||||
|
||||
function ingredientName(ingredient) {
|
||||
return normalizeIngredientName(typeof ingredient === 'string' ? ingredient : ingredient?.name);
|
||||
}
|
||||
|
||||
function inventoryMap(inventory) {
|
||||
const map = new Map();
|
||||
for (const item of toArray(inventory)) {
|
||||
const normalized = normalizeIngredientName(item.normalizedName || item.normalized_name || item.name);
|
||||
if (!normalized) continue;
|
||||
const existing = map.get(normalized) || { name: item.name || normalized, portions: 0, expiresOn: null, items: [] };
|
||||
existing.portions += Number(item.portions || 0);
|
||||
existing.expiresOn = earlierDate(existing.expiresOn, item.expiresOn || item.expires_on || null);
|
||||
existing.items.push(item);
|
||||
map.set(normalized, existing);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function earlierDate(a, b) {
|
||||
if (!a) return b;
|
||||
if (!b) return a;
|
||||
return String(a) < String(b) ? a : b;
|
||||
}
|
||||
|
||||
function daysUntil(dateStr, today = new Date().toISOString().slice(0, 10)) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(dateStr || ''))) return null;
|
||||
const ms = Date.parse(`${dateStr}T00:00:00Z`) - Date.parse(`${today}T00:00:00Z`);
|
||||
return Math.round(ms / 86400000);
|
||||
}
|
||||
|
||||
function tagsFor(meal) {
|
||||
const parsed = typeof meal.tags_json === 'string' ? safeJson(meal.tags_json, []) : [];
|
||||
return new Set([...toArray(meal.tags), ...parsed, meal.meal_category, meal.protein, meal.style]
|
||||
.map((tag) => String(tag || '').toLowerCase().trim()).filter(Boolean));
|
||||
}
|
||||
|
||||
function safeJson(value, fallback) {
|
||||
try { return JSON.parse(value); } catch { return fallback; }
|
||||
}
|
||||
|
||||
function mealIngredients(meal) {
|
||||
return toArray(meal.ingredients).map((ing) => ({
|
||||
...((typeof ing === 'string') ? { name: ing } : ing),
|
||||
normalizedName: ingredientName(ing),
|
||||
})).filter((ing) => ing.normalizedName);
|
||||
}
|
||||
|
||||
function groceryDeltaFor(meal, inventory, pantryStaples = []) {
|
||||
const inv = inventoryMap(inventory);
|
||||
const pantry = new Set(toArray(pantryStaples).map(normalizeIngredientName).filter(Boolean));
|
||||
const usesInventory = [];
|
||||
const pantryCovered = [];
|
||||
const missing = [];
|
||||
for (const ingredient of mealIngredients(meal)) {
|
||||
if (inv.has(ingredient.normalizedName)) {
|
||||
usesInventory.push(ingredient.normalizedName);
|
||||
} else if (pantry.has(ingredient.normalizedName)) {
|
||||
pantryCovered.push(ingredient.normalizedName);
|
||||
} else {
|
||||
missing.push(ingredient.normalizedName);
|
||||
}
|
||||
}
|
||||
return {
|
||||
newItems: new Set(missing).size,
|
||||
usesInventory: [...new Set(usesInventory)],
|
||||
pantryCovered: [...new Set(pantryCovered)],
|
||||
missingItems: [...new Set(missing)],
|
||||
};
|
||||
}
|
||||
|
||||
function hardBlocked(meal, preferences) {
|
||||
const allTargets = new Set([
|
||||
String(meal.name || meal.title || '').toLowerCase(),
|
||||
...mealIngredients(meal).map((i) => i.normalizedName),
|
||||
...tagsFor(meal),
|
||||
]);
|
||||
for (const pref of toArray(preferences)) {
|
||||
if ((pref.type || pref.preference) !== 'allergy') continue;
|
||||
const target = normalizeIngredientName(pref.target || pref.name || pref.ingredient || pref.value);
|
||||
if (target && allTargets.has(target)) return `Blocked: allergy/diet conflict with ${target}.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function preferenceEffect(meal, preferences) {
|
||||
let score = 0;
|
||||
const reasons = [];
|
||||
const warnings = [];
|
||||
const allTargets = new Set([
|
||||
String(meal.name || meal.title || '').toLowerCase(),
|
||||
...mealIngredients(meal).map((i) => i.normalizedName),
|
||||
...tagsFor(meal),
|
||||
]);
|
||||
for (const pref of toArray(preferences)) {
|
||||
const type = pref.type || pref.preference;
|
||||
const rawTarget = pref.target || pref.name || pref.ingredient || pref.value;
|
||||
const target = normalizeIngredientName(rawTarget) || String(rawTarget || '').toLowerCase();
|
||||
if (!target || !allTargets.has(target)) continue;
|
||||
const strength = pref.strength === 'high' ? 3 : pref.strength === 'low' ? 1 : 2;
|
||||
if (type === 'like' || type === 'favorite') { score += strength * 2; reasons.push(`Family signal likes ${target}.`); }
|
||||
if (type === 'dislike') { score -= strength * 3; warnings.push(`Preference risk: ${target} is disliked.`); }
|
||||
}
|
||||
return { score, reasons, warnings };
|
||||
}
|
||||
|
||||
function recencyPenalty(meal, recentMeals) {
|
||||
const title = String(meal.name || meal.title || '').toLowerCase().trim();
|
||||
const category = String(meal.meal_category || '').toLowerCase();
|
||||
let penalty = 0;
|
||||
for (const recent of toArray(recentMeals)) {
|
||||
const recentTitle = String(recent.name || recent.title || '').toLowerCase().trim();
|
||||
if (title && recentTitle === title) penalty -= 8;
|
||||
if (category && category === String(recent.meal_category || '').toLowerCase()) penalty -= 2;
|
||||
}
|
||||
return penalty;
|
||||
}
|
||||
|
||||
function dayLabels(dayContext = {}) {
|
||||
return new Set([
|
||||
...toArray(dayContext.labels),
|
||||
dayContext.busyness === 'high' ? 'busy_evening' : null,
|
||||
dayContext.energy === 'low' ? 'low_energy' : null,
|
||||
dayContext.guests ? 'guests' : null,
|
||||
dayContext.soloParent ? 'solo_parent' : null,
|
||||
].filter(Boolean));
|
||||
}
|
||||
|
||||
export function scoreMealSuggestions({ meals = [], dayContext = {}, preferences = [], inventory = [], recentMeals = [], pantryStaples = [], today } = {}) {
|
||||
const labels = dayLabels(dayContext);
|
||||
const inv = inventoryMap(inventory);
|
||||
return toArray(meals).map((meal) => {
|
||||
const blocked = hardBlocked(meal, preferences);
|
||||
const mealName = meal.name || meal.title || 'Untitled meal';
|
||||
const tags = tagsFor(meal);
|
||||
const ingredients = mealIngredients(meal);
|
||||
const groceryDelta = groceryDeltaFor(meal, inventory, pantryStaples);
|
||||
const reasons = [];
|
||||
const warnings = [];
|
||||
let score = 50;
|
||||
|
||||
if (blocked) {
|
||||
return { mealId: meal.id, mealName, score: -9999, fitLabels: ['blocked'], reasons: [], warnings: [blocked], groceryDelta };
|
||||
}
|
||||
|
||||
const effort = meal.effort || (tags.has('quick') ? 'easy' : 'normal');
|
||||
const cleanup = meal.cleanup || 'medium';
|
||||
const interruptionTolerance = meal.interruptionTolerance || meal.interruption_tolerance || (tags.has('leftovers') ? 'high' : 'medium');
|
||||
const activeMinutes = Number(meal.activeMinutes ?? meal.active_minutes ?? meal.active_time_minutes ?? 30);
|
||||
const totalMinutes = Number(meal.totalMinutes ?? meal.total_minutes ?? 45);
|
||||
|
||||
score += EFFORT_SCORE[effort] ?? 0;
|
||||
score += CLEANUP_SCORE[cleanup] ?? 0;
|
||||
score += INTERRUPTION_SCORE[interruptionTolerance] ?? 0;
|
||||
score += KID_SCORE[meal.kidFit || meal.kid_fit] ?? 0;
|
||||
|
||||
if (labels.has('busy_evening') || labels.has('late_activity') || dayContext.dinnerWindowMinutes <= 30) {
|
||||
if (activeMinutes <= 15) { score += 10; reasons.push(`Fits a tight day: ${activeMinutes} min active time.`); }
|
||||
else if (activeMinutes > 30) { score -= 12; warnings.push(`Busy-day mismatch: ${activeMinutes} min active time.`); }
|
||||
if (cleanup === 'low') { score += 5; reasons.push('Low cleanup helps on a busy evening.'); }
|
||||
if (cleanup === 'high') { score -= 8; warnings.push('High cleanup on a busy evening.'); }
|
||||
if (interruptionTolerance === 'high') { score += 5; reasons.push('Can survive interruptions.'); }
|
||||
if (interruptionTolerance === 'low') { score -= 8; warnings.push('Needs continuous attention on an interruption-heavy day.'); }
|
||||
}
|
||||
|
||||
if (labels.has('low_energy') || labels.has('solo_parent')) {
|
||||
if (effort === 'survival' || effort === 'easy') { score += 8; reasons.push(`Low-energy fit: ${effort} effort.`); }
|
||||
if (effort === 'project') { score -= 18; warnings.push('Project meal is a bad fit for low parent energy.'); }
|
||||
}
|
||||
|
||||
if (labels.has('guests')) {
|
||||
if (meal.guestFit || meal.guest_fit || meal.batchFriendly || meal.batch_friendly) { score += 35; reasons.push('Guest-capable / batch-friendly.'); }
|
||||
else { score -= 15; warnings.push('Not marked as guest-capable.'); }
|
||||
}
|
||||
|
||||
if (dayContext.weather === 'hot' && tags.has('oven')) { score -= 4; warnings.push('Oven-heavy meal on a hot day.'); }
|
||||
if ((dayContext.weather === 'cold' || dayContext.weather === 'rainy') && (tags.has('soup') || tags.has('cozy'))) { score += 3; reasons.push('Season/weather tie-breaker fits.'); }
|
||||
|
||||
for (const ingredient of ingredients) {
|
||||
const available = inv.get(ingredient.normalizedName);
|
||||
if (!available) continue;
|
||||
score += 3;
|
||||
const due = daysUntil(available.expiresOn, today);
|
||||
if (due !== null && due <= 1) {
|
||||
score += 8;
|
||||
reasons.push(`Uses ${ingredient.normalizedName} before it expires.`);
|
||||
} else {
|
||||
reasons.push(`Uses available ${ingredient.normalizedName}.`);
|
||||
}
|
||||
}
|
||||
|
||||
const pref = preferenceEffect(meal, preferences);
|
||||
score += pref.score;
|
||||
reasons.push(...pref.reasons);
|
||||
warnings.push(...pref.warnings);
|
||||
|
||||
const repetition = recencyPenalty(meal, recentMeals);
|
||||
if (repetition < 0) {
|
||||
score += repetition;
|
||||
warnings.push('Recently eaten / similar category, so it is demoted for variety.');
|
||||
}
|
||||
|
||||
if (groceryDelta.newItems <= 2) { score += 7; reasons.push(`Small grocery delta: ${groceryDelta.newItems} new items.`); }
|
||||
else if (groceryDelta.newItems >= 8) { score -= 8; warnings.push(`Heavy grocery burden: ${groceryDelta.newItems} new items.`); }
|
||||
|
||||
const fitLabels = [];
|
||||
if (activeMinutes <= 15) fitLabels.push('quick-active-time');
|
||||
if (cleanup === 'low') fitLabels.push('low-cleanup');
|
||||
if (interruptionTolerance === 'high') fitLabels.push('interruption-friendly');
|
||||
if (groceryDelta.usesInventory.length) fitLabels.push('uses-inventory');
|
||||
if (groceryDelta.newItems <= 2) fitLabels.push('low-grocery-delta');
|
||||
if (meal.guestFit || meal.guest_fit) fitLabels.push('guest-capable');
|
||||
|
||||
return {
|
||||
mealId: meal.id,
|
||||
mealName,
|
||||
score: Math.round(score),
|
||||
fitLabels,
|
||||
reasons: [...new Set(reasons)].slice(0, 5),
|
||||
warnings: [...new Set(warnings)].slice(0, 5),
|
||||
groceryDelta,
|
||||
totalMinutes,
|
||||
activeMinutes,
|
||||
};
|
||||
}).sort((a, b) => b.score - a.score || String(a.mealName).localeCompare(String(b.mealName)));
|
||||
}
|
||||
|
||||
export function generateGroceryList(selectedMeals = [], { inventory = [], pantryStaples = [] } = {}) {
|
||||
const inv = inventoryMap(inventory);
|
||||
const pantry = new Set(toArray(pantryStaples).map(normalizeIngredientName).filter(Boolean));
|
||||
const items = new Map();
|
||||
const coveredByInventory = [];
|
||||
for (const meal of toArray(selectedMeals)) {
|
||||
for (const ingredient of mealIngredients(meal)) {
|
||||
const normalized = ingredient.normalizedName;
|
||||
if (!normalized) continue;
|
||||
if (inv.has(normalized)) {
|
||||
coveredByInventory.push({ name: normalized, sourceMealId: meal.id, sourceMealName: meal.name || meal.title });
|
||||
continue;
|
||||
}
|
||||
if (pantry.has(normalized)) continue;
|
||||
const existing = items.get(normalized) || { name: normalized, quantity: ingredient.quantity || null, category: ingredient.category || null, sourceMeals: [] };
|
||||
existing.sourceMeals.push({ id: meal.id, name: meal.name || meal.title });
|
||||
items.set(normalized, existing);
|
||||
}
|
||||
}
|
||||
return {
|
||||
items: [...items.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
coveredByInventory,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Modul: Meal-Fit-Test
|
||||
* Zweck: Validiert deterministic context-aware meal suggestions and grocery dedupe.
|
||||
* Ausführen: node test-meal-fit.js
|
||||
*/
|
||||
|
||||
import { generateGroceryList, normalizeIngredientName, scoreMealSuggestions } from './server/services/meal-fit.js';
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try { fn(); console.log(` ✓ ${name}`); passed++; }
|
||||
catch (err) { console.error(` ✗ ${name}: ${err.message}`); failed++; }
|
||||
}
|
||||
function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion fehlgeschlagen'); }
|
||||
function topName(result) { return result[0]?.mealName; }
|
||||
|
||||
const meals = [
|
||||
{
|
||||
id: 'wraps',
|
||||
name: 'Chicken wraps',
|
||||
ingredients: ['tortilla wraps', 'leftover chicken', 'cucumber', 'cheddar cheese'],
|
||||
activeMinutes: 12,
|
||||
totalMinutes: 15,
|
||||
effort: 'easy',
|
||||
cleanup: 'low',
|
||||
interruptionTolerance: 'high',
|
||||
kidFit: 'safe',
|
||||
meal_category: 'chicken',
|
||||
style: 'quick',
|
||||
tags: ['portable'],
|
||||
},
|
||||
{
|
||||
id: 'lasagna',
|
||||
name: 'Sunday lasagna',
|
||||
ingredients: ['beef mince', 'tomatoes', 'pasta sheets', 'cheddar cheese'],
|
||||
activeMinutes: 50,
|
||||
totalMinutes: 110,
|
||||
effort: 'project',
|
||||
cleanup: 'high',
|
||||
interruptionTolerance: 'low',
|
||||
kidFit: 'safe',
|
||||
guestFit: true,
|
||||
batchFriendly: true,
|
||||
meal_category: 'pasta',
|
||||
tags: ['oven', 'cozy'],
|
||||
},
|
||||
{
|
||||
id: 'fish',
|
||||
name: 'Fish curry',
|
||||
ingredients: ['fish', 'rice', 'curry paste', 'coconut milk'],
|
||||
activeMinutes: 35,
|
||||
totalMinutes: 40,
|
||||
effort: 'normal',
|
||||
cleanup: 'medium',
|
||||
interruptionTolerance: 'low',
|
||||
kidFit: 'risky',
|
||||
meal_category: 'fish',
|
||||
style: 'family',
|
||||
},
|
||||
{
|
||||
id: 'pancakes',
|
||||
name: 'Pancakes',
|
||||
ingredients: ['flour', 'milk', 'eggs'],
|
||||
activeMinutes: 25,
|
||||
totalMinutes: 30,
|
||||
effort: 'easy',
|
||||
cleanup: 'medium',
|
||||
interruptionTolerance: 'medium',
|
||||
kidFit: 'safe',
|
||||
meal_category: 'breakfast',
|
||||
},
|
||||
];
|
||||
|
||||
console.log('\n[Meal-Fit-Test] Context-aware dinner planner\n');
|
||||
|
||||
test('ingredient normalization dedupes obvious variants', () => {
|
||||
assert(normalizeIngredientName('Grated cheddar') === 'cheddar');
|
||||
assert(normalizeIngredientName('cheddar cheese') === 'cheddar');
|
||||
assert(normalizeIngredientName('Tortilla wraps') === 'tortillas');
|
||||
});
|
||||
|
||||
test('busy low-energy day promotes quick low-cleanup interruption-friendly leftover meal', () => {
|
||||
const suggestions = scoreMealSuggestions({
|
||||
meals,
|
||||
dayContext: { busyness: 'high', energy: 'low', dinnerWindowMinutes: 25, labels: ['late_activity'] },
|
||||
inventory: [{ name: 'leftover chicken', portions: 2, expiresOn: '2026-05-24' }],
|
||||
today: '2026-05-23',
|
||||
});
|
||||
assert(topName(suggestions) === 'Chicken wraps', `Expected Chicken wraps, got ${topName(suggestions)}`);
|
||||
const top = suggestions[0];
|
||||
assert(top.fitLabels.includes('quick-active-time'));
|
||||
assert(top.fitLabels.includes('low-cleanup'));
|
||||
assert(top.fitLabels.includes('uses-inventory'));
|
||||
assert(top.reasons.some((reason) => reason.includes('tight day') || reason.includes('Low-energy')));
|
||||
});
|
||||
|
||||
test('normal guest day promotes guest-capable batch meal over emergency wrap default', () => {
|
||||
const suggestions = scoreMealSuggestions({
|
||||
meals,
|
||||
dayContext: { busyness: 'low', energy: 'high', guests: true, dinnerWindowMinutes: 150 },
|
||||
pantryStaples: ['pasta sheets'],
|
||||
});
|
||||
assert(topName(suggestions) === 'Sunday lasagna', `Expected Sunday lasagna, got ${topName(suggestions)}`);
|
||||
assert(suggestions[0].fitLabels.includes('guest-capable'));
|
||||
});
|
||||
|
||||
test('allergy blocks instead of merely warning', () => {
|
||||
const suggestions = scoreMealSuggestions({
|
||||
meals,
|
||||
preferences: [{ type: 'allergy', target: 'fish', strength: 'high' }],
|
||||
});
|
||||
const fish = suggestions.find((suggestion) => suggestion.mealId === 'fish');
|
||||
assert(fish.score < -1000);
|
||||
assert(fish.warnings.some((warning) => warning.includes('allergy')));
|
||||
});
|
||||
|
||||
test('dislike and recent repetition demote risky meals with grounded warnings', () => {
|
||||
const suggestions = scoreMealSuggestions({
|
||||
meals,
|
||||
preferences: [{ type: 'dislike', target: 'fish', strength: 'high' }],
|
||||
recentMeals: [{ title: 'Fish curry', meal_category: 'fish' }],
|
||||
});
|
||||
const fish = suggestions.find((suggestion) => suggestion.mealId === 'fish');
|
||||
assert(fish.warnings.some((warning) => warning.includes('disliked')));
|
||||
assert(fish.warnings.some((warning) => warning.includes('Recently eaten')));
|
||||
assert(suggestions.indexOf(fish) > 0, 'Fish should not rank first after dislike + repetition');
|
||||
});
|
||||
|
||||
test('inventory matching is concrete, not broad tag magic', () => {
|
||||
const suggestions = scoreMealSuggestions({
|
||||
meals: [{ id: 'taggy', name: 'Mystery pasta', ingredients: ['tomatoes'], meal_category: 'pasta', tags: ['pasta'] }],
|
||||
inventory: [{ name: 'pasta', expiresOn: '2026-05-24' }],
|
||||
today: '2026-05-23',
|
||||
});
|
||||
assert(!suggestions[0].fitLabels.includes('uses-inventory'), 'Broad pasta tag must not count as concrete inventory use');
|
||||
assert(!suggestions[0].reasons.some((reason) => reason.includes('Uses pasta')));
|
||||
});
|
||||
|
||||
test('grocery list dedupes normalized ingredient names and tracks source meals', () => {
|
||||
const grocery = generateGroceryList([
|
||||
meals[0],
|
||||
{ id: 'quesadillas', name: 'Quesadillas', ingredients: ['tortillas', 'grated cheddar', 'pepper'] },
|
||||
], { inventory: [{ name: 'leftover chicken' }], pantryStaples: ['pepper'] });
|
||||
const names = grocery.items.map((item) => item.name);
|
||||
assert(names.filter((name) => name === 'cheddar').length === 1, `Expected one cheddar, got ${names.join(', ')}`);
|
||||
assert(names.filter((name) => name === 'tortillas').length === 1, `Expected one tortillas, got ${names.join(', ')}`);
|
||||
const cheddar = grocery.items.find((item) => item.name === 'cheddar');
|
||||
assert(cheddar.sourceMeals.length === 2, 'Cheddar should link both source meals');
|
||||
assert(grocery.coveredByInventory.some((item) => item.name === 'chicken'), 'Leftover chicken should be inventory-covered');
|
||||
});
|
||||
|
||||
console.log(`\n[Meal-Fit-Test] Ergebnis: ${passed} bestanden, ${failed} fehlgeschlagen\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
Reference in New Issue
Block a user