1367 lines
56 KiB
JavaScript
1367 lines
56 KiB
JavaScript
/**
|
||
* Modul: Essensplan (Meals)
|
||
* Zweck: Wochenansicht mit Mahlzeit-CRUD, Zutaten-Verwaltung und Einkaufslisten-Integration
|
||
* Abhängigkeiten: /api.js, /router.js (window.oikos)
|
||
*/
|
||
|
||
import { api } from '/api.js';
|
||
import { openModal as openSharedModal, closeModal as closeSharedModal, selectModal } from '/components/modal.js';
|
||
import { stagger } from '/utils/ux.js';
|
||
import { t, formatDate, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid } from '/i18n.js';
|
||
import { esc } from '/utils/html.js';
|
||
import { DEFAULT_CATEGORY_NAME, categoryLabel } from '/utils/shopping-categories.js';
|
||
import { renderKitchenTabsBar } from '/utils/kitchen-tabs.js';
|
||
|
||
// --------------------------------------------------------
|
||
// Konstanten
|
||
// --------------------------------------------------------
|
||
|
||
const MEAL_TYPES = () => [
|
||
{ key: 'breakfast', label: t('meals.typeBreakfast'), icon: 'sunrise' },
|
||
{ key: 'lunch', label: t('meals.typeLunch'), icon: 'sun' },
|
||
{ key: 'dinner', label: t('meals.typeDinner'), icon: 'moon' },
|
||
{ key: 'snack', label: t('meals.typeSnack'), icon: 'cookie' },
|
||
];
|
||
|
||
const DAY_NAMES = () => [
|
||
t('meals.dayMo'), t('meals.dayDi'), t('meals.dayMi'), t('meals.dayDo'),
|
||
t('meals.dayFr'), t('meals.daySa'), t('meals.daySo'),
|
||
];
|
||
|
||
const EXCLUDED_MEAL_CATEGORY_NAMES = new Set(['Haushalt', 'Drogerie']);
|
||
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']];
|
||
|
||
// --------------------------------------------------------
|
||
// State
|
||
// --------------------------------------------------------
|
||
|
||
let state = {
|
||
currentWeek: null, // YYYY-MM-DD (Montag)
|
||
meals: [],
|
||
recipes: [],
|
||
recipeSignals: [],
|
||
familyMembers: [], // Familienmitglieder für Koch-Zuweisung
|
||
lists: [], // Einkaufslisten für Transfer-Dropdown
|
||
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)
|
||
let _container = null;
|
||
|
||
// --------------------------------------------------------
|
||
// Datumshelfer
|
||
// --------------------------------------------------------
|
||
|
||
function getMondayOf(dateStr) {
|
||
const d = new Date(dateStr + 'T00:00:00Z');
|
||
const day = d.getUTCDay();
|
||
const diff = (day === 0 ? -6 : 1 - day);
|
||
d.setUTCDate(d.getUTCDate() + diff);
|
||
return d.toISOString().slice(0, 10);
|
||
}
|
||
|
||
function addDays(dateStr, n) {
|
||
const d = new Date(dateStr + 'T00:00:00Z');
|
||
d.setUTCDate(d.getUTCDate() + n);
|
||
return d.toISOString().slice(0, 10);
|
||
}
|
||
|
||
function formatWeekLabel(monday) {
|
||
const sunday = addDays(monday, 6);
|
||
return `${formatDate(monday)} – ${formatDate(sunday)}`;
|
||
}
|
||
|
||
function isToday(dateStr) {
|
||
return dateStr === new Date().toISOString().slice(0, 10);
|
||
}
|
||
|
||
function formatDayDate(dateStr) {
|
||
return formatDate(dateStr);
|
||
}
|
||
|
||
function mealCategories() {
|
||
return state.categories.filter((c) => !EXCLUDED_MEAL_CATEGORY_NAMES.has(c.name));
|
||
}
|
||
|
||
function optionHtml(options, selected) {
|
||
return options.map(([value, label]) => `<option value="${esc(value)}" ${value === selected ? 'selected' : ''}>${esc(label)}</option>`).join('');
|
||
}
|
||
|
||
function optionLabel(options, value) {
|
||
return options.find(([optionValue]) => optionValue === value)?.[1] || value || '';
|
||
}
|
||
|
||
function renderTaxonomyChip(kind, value, label) {
|
||
if (!value || !label) return '';
|
||
return `<span class="meal-taxonomy-chip meal-taxonomy-chip--${esc(kind)}" data-meal-taxonomy="${esc(kind)}" data-taxonomy-value="${esc(value)}">${esc(label)}</span>`;
|
||
}
|
||
|
||
function renderMealTaxonomyChips(meal) {
|
||
return [
|
||
meal.meal_category ? renderTaxonomyChip('category', meal.meal_category, optionLabel(MEAL_CATEGORY_OPTIONS, meal.meal_category)) : '',
|
||
meal.protein ? renderTaxonomyChip('protein', meal.protein, optionLabel(PROTEIN_OPTIONS, meal.protein)) : '',
|
||
meal.style ? renderTaxonomyChip('style', meal.style, optionLabel(STYLE_OPTIONS, meal.style)) : '',
|
||
meal.leftover_from_meal_id ? renderTaxonomyChip('leftovers', 'linked', '↻ Rester') : '',
|
||
].filter(Boolean).join('');
|
||
}
|
||
|
||
// --------------------------------------------------------
|
||
// API-Wrapper
|
||
// --------------------------------------------------------
|
||
|
||
async function loadWeek(week) {
|
||
try {
|
||
const res = await api.get(`/meals?week=${week}`);
|
||
state.meals = res.data;
|
||
state.currentWeek = getMondayOf(week);
|
||
} catch (err) {
|
||
console.error('[Meals] loadWeek Fehler:', err);
|
||
state.meals = [];
|
||
state.currentWeek = getMondayOf(week);
|
||
window.oikos?.showToast(t('meals.loadError'), 'danger');
|
||
}
|
||
}
|
||
|
||
async function loadLists() {
|
||
try {
|
||
const res = await api.get('/shopping');
|
||
state.lists = res.data;
|
||
} catch {
|
||
state.lists = [];
|
||
}
|
||
}
|
||
|
||
async function loadCategories() {
|
||
try {
|
||
const res = await api.get('/shopping/categories');
|
||
state.categories = res.data;
|
||
} catch {
|
||
state.categories = [];
|
||
}
|
||
}
|
||
|
||
async function loadRecipes() {
|
||
try {
|
||
const res = await api.get('/recipes');
|
||
state.recipes = res.data;
|
||
} catch {
|
||
state.recipes = [];
|
||
}
|
||
}
|
||
|
||
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 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 res = await api.put(`/meal-planning/recipe-signals/${recipeId}`, {
|
||
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,
|
||
});
|
||
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;
|
||
}
|
||
|
||
async function loadPreferences() {
|
||
try {
|
||
const res = await api.get('/preferences');
|
||
state.visibleMealTypes = res.data.visible_meal_types ?? state.visibleMealTypes;
|
||
} catch {
|
||
// Default beibehalten
|
||
}
|
||
}
|
||
|
||
// --------------------------------------------------------
|
||
// Render
|
||
// --------------------------------------------------------
|
||
|
||
export async function render(container, { user }) {
|
||
_container = container;
|
||
container.innerHTML = `
|
||
<div class="meals-page">
|
||
<h1 class="sr-only">${t('meals.title')}</h1>
|
||
<div class="week-nav">
|
||
<button class="btn btn--icon" id="week-prev" aria-label="${t('meals.prevWeek')}">
|
||
<i data-lucide="chevron-left" aria-hidden="true"></i>
|
||
</button>
|
||
<span class="week-nav__label" id="week-label"></span>
|
||
<button class="week-nav__today" id="week-today">${t('meals.today')}</button>
|
||
<button class="btn btn--icon" id="week-next" aria-label="${t('meals.nextWeek')}">
|
||
<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>
|
||
<button class="page-fab" id="fab-new-meal" aria-label="${t('meals.addMealTitle')}">
|
||
<i data-lucide="plus" style="width:24px;height:24px" aria-hidden="true"></i>
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
if (window.lucide) lucide.createIcons();
|
||
renderKitchenTabsBar(container, '/meals');
|
||
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
const params = new URLSearchParams(window.location.search);
|
||
const requestedWeek = params.get('week') || params.get('date') || today;
|
||
const monday = getMondayOf(requestedWeek);
|
||
|
||
await Promise.all([loadWeek(monday), loadLists(), loadPreferences(), loadCategories(), loadRecipes(), loadFamilyMembers(), loadRecipeSignals()]);
|
||
renderWeekGrid();
|
||
renderMealFitPanel();
|
||
wireNav();
|
||
wireMealFitPanel();
|
||
|
||
const selectedRecipeId = Number(params.get('recipe'));
|
||
if (selectedRecipeId) {
|
||
const selectedRecipe = state.recipes.find((r) => r.id === selectedRecipeId);
|
||
if (selectedRecipe) {
|
||
const firstType = state.visibleMealTypes[0] ?? 'lunch';
|
||
openMealModal({ mode: 'create', date: today, mealType: firstType, presetRecipeId: selectedRecipe.id });
|
||
}
|
||
}
|
||
|
||
container.querySelector('#fab-new-meal').addEventListener('click', () => {
|
||
const firstType = state.visibleMealTypes[0] ?? 'lunch';
|
||
openMealModal({ mode: 'create', date: today, mealType: firstType });
|
||
});
|
||
}
|
||
|
||
// --------------------------------------------------------
|
||
// Wochengitter
|
||
// --------------------------------------------------------
|
||
|
||
function renderWeekGrid() {
|
||
const grid = _container.querySelector('#week-grid');
|
||
if (!grid) return;
|
||
|
||
_container.querySelector('#week-label').textContent =
|
||
formatWeekLabel(state.currentWeek);
|
||
|
||
const days = Array.from({ length: 7 }, (_, i) => addDays(state.currentWeek, i));
|
||
const dayNames = DAY_NAMES();
|
||
|
||
grid.innerHTML = days.map((date, idx) => {
|
||
const mealsForDay = state.meals.filter((m) => m.date === date);
|
||
const todayClass = isToday(date) ? 'day-header--today' : '';
|
||
|
||
return `
|
||
<div class="day-column">
|
||
<div class="day-header ${todayClass}">
|
||
<span class="day-header__name">${dayNames[idx]}</span>
|
||
<span class="day-header__date">${formatDayDate(date)}</span>
|
||
</div>
|
||
<div class="day-slots">
|
||
${MEAL_TYPES().filter((type) => state.visibleMealTypes.includes(type.key)).map((type) => renderSlot(date, type, mealsForDay)).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
if (window.lucide) lucide.createIcons();
|
||
stagger(grid.querySelectorAll('.meal-card'));
|
||
wireGrid(grid);
|
||
}
|
||
|
||
function renderSlot(date, type, mealsForDay) {
|
||
const meal = mealsForDay.find((m) => m.meal_type === type.key);
|
||
|
||
if (!meal) {
|
||
return `
|
||
<div class="meal-slot meal-slot--empty" data-date="${date}" data-type="${type.key}">
|
||
<div class="meal-slot__type-label">${type.label}</div>
|
||
<div class="empty-state empty-state--compact">
|
||
<div class="empty-state__description">${t('meals.noMealPlanned')}</div>
|
||
</div>
|
||
<button
|
||
class="meal-slot__add-btn"
|
||
data-action="add-meal"
|
||
data-date="${date}"
|
||
data-type="${type.key}"
|
||
aria-label="${t('meals.addMeal', { type: type.label })}"
|
||
>
|
||
<i data-lucide="plus" style="width:16px;height:16px;" aria-hidden="true"></i>
|
||
</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
const ingCount = meal.ingredients?.length ?? 0;
|
||
const ingDone = meal.ingredients?.filter((i) => i.on_shopping_list).length ?? 0;
|
||
const ingLabel = ingCount > 0 ? (ingCount !== 1 ? t('meals.ingredientCountPlural', { count: ingCount }) : t('meals.ingredientCount', { count: ingCount })) : '';
|
||
const ingDoneLabel = ingCount > 0 && ingDone === ingCount ? ' ✓' : '';
|
||
const canTransfer = ingCount > 0 && ingDone < ingCount;
|
||
const cookName = meal.cook_assignment?.cook_name;
|
||
const taxonomyChips = renderMealTaxonomyChips(meal);
|
||
|
||
return `
|
||
<div class="meal-slot meal-slot--has-meal" data-meal-id="${meal.id}" data-date="${meal.date}" data-type="${type.key}">
|
||
<div class="meal-slot__type-label">${type.label}</div>
|
||
<div class="meal-card"
|
||
data-action="edit-meal"
|
||
data-meal-id="${meal.id}"
|
||
role="button" tabindex="0">
|
||
<div class="meal-card__title">${esc(meal.title)}</div>
|
||
${taxonomyChips ? `<div class="meal-card__taxonomy" aria-label="Meal classification">${taxonomyChips}</div>` : ''}
|
||
${(ingLabel || cookName) ? `<div class="meal-card__meta">
|
||
${ingLabel ? `<span class="meal-card__ingredients-count">${ingLabel}${esc(ingDoneLabel)}</span>` : ''}
|
||
${cookName ? `<span class="meal-card__cook"><i data-lucide="chef-hat" style="width:13px;height:13px;" aria-hidden="true"></i>${esc(cookName)}</span>` : ''}
|
||
</div>` : ''}
|
||
<div class="meal-card__actions">
|
||
${meal.recipe_url ? `<a class="meal-card__action-btn meal-card__action-btn--recipe"
|
||
data-action="open-recipe"
|
||
href="${esc(meal.recipe_url)}"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
aria-label="${t('meals.openRecipe')}"
|
||
><i data-lucide="link" style="width:14px;height:14px;" aria-hidden="true"></i></a>` : ''}
|
||
${canTransfer ? `<button class="meal-card__action-btn meal-card__action-btn--shopping"
|
||
data-action="transfer-meal"
|
||
data-meal-id="${meal.id}"
|
||
aria-label="${t('meals.transferToShoppingList')}"
|
||
><i data-lucide="shopping-cart" style="width:14px;height:14px;" aria-hidden="true"></i></button>` : ''}
|
||
<button class="meal-card__action-btn"
|
||
data-action="delete-meal"
|
||
data-meal-id="${meal.id}"
|
||
aria-label="${t('meals.deleteMeal')}"
|
||
><i data-lucide="trash-2" style="width:14px;height:14px;" aria-hidden="true"></i></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// --------------------------------------------------------
|
||
// 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
|
||
// --------------------------------------------------------
|
||
|
||
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 () => {
|
||
const monday = getMondayOf(new Date().toISOString().slice(0, 10));
|
||
if (monday === state.currentWeek) return;
|
||
await loadWeek(monday);
|
||
renderWeekGrid();
|
||
renderMealFitPanel();
|
||
});
|
||
}
|
||
|
||
function wireGrid(grid) {
|
||
grid.addEventListener('click', async (e) => {
|
||
const btn = e.target.closest('[data-action]');
|
||
if (!btn) return;
|
||
|
||
const action = btn.dataset.action;
|
||
|
||
if (action === 'add-meal') {
|
||
openMealModal({ mode: 'create', date: btn.dataset.date, mealType: btn.dataset.type });
|
||
return;
|
||
}
|
||
|
||
if (action === 'open-recipe') {
|
||
// Link öffnet sich nativ - nur Bubbling stoppen damit kein Edit-Modal aufgeht
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
|
||
if (action === 'edit-meal') {
|
||
const mealId = parseInt(btn.dataset.mealId, 10);
|
||
const meal = state.meals.find((m) => m.id === mealId);
|
||
if (meal) openMealModal({ mode: 'edit', meal, date: meal.date, mealType: meal.meal_type });
|
||
return;
|
||
}
|
||
|
||
if (action === 'delete-meal') {
|
||
await deleteMeal(parseInt(btn.dataset.mealId, 10));
|
||
return;
|
||
}
|
||
|
||
if (action === 'transfer-meal') {
|
||
await transferMeal(parseInt(btn.dataset.mealId, 10));
|
||
}
|
||
});
|
||
|
||
grid.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
const card = e.target.closest('[data-action="edit-meal"]');
|
||
if (card) { e.preventDefault(); card.click(); }
|
||
}
|
||
});
|
||
|
||
wireDragDrop(grid);
|
||
}
|
||
|
||
// --------------------------------------------------------
|
||
// Drag & Drop
|
||
// --------------------------------------------------------
|
||
|
||
let _suppressNextClick = false;
|
||
|
||
function wireDragDrop(grid) {
|
||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||
let dragging = null; // { mealId, sourceDate, sourceType, ghost, startX, startY }
|
||
|
||
grid.addEventListener('pointerdown', (e) => {
|
||
const card = e.target.closest('.meal-card');
|
||
if (!card) return;
|
||
if (e.target.closest('[data-action="delete-meal"], [data-action="transfer-meal"], [data-action="open-recipe"]')) return;
|
||
|
||
const slot = card.closest('.meal-slot');
|
||
if (!slot) return;
|
||
|
||
const mealId = parseInt(slot.dataset.mealId, 10);
|
||
const sourceDate = slot.dataset.date;
|
||
const sourceType = slot.dataset.type;
|
||
|
||
e.preventDefault();
|
||
card.setPointerCapture(e.pointerId);
|
||
|
||
let ghost = null;
|
||
if (!reducedMotion) {
|
||
ghost = card.cloneNode(true);
|
||
ghost.classList.add('meal-card--ghost');
|
||
ghost.style.width = card.offsetWidth + 'px';
|
||
ghost.style.height = card.offsetHeight + 'px';
|
||
ghost.style.left = (e.clientX - card.offsetWidth / 2) + 'px';
|
||
ghost.style.top = (e.clientY - card.offsetHeight / 2) + 'px';
|
||
document.body.appendChild(ghost);
|
||
}
|
||
|
||
slot.classList.add('meal-slot--dragging');
|
||
dragging = { mealId, sourceDate, sourceType, ghost, card, slot };
|
||
|
||
let lastTarget = null;
|
||
|
||
function onMove(ev) {
|
||
if (!dragging) return;
|
||
if (ghost) {
|
||
ghost.style.left = (ev.clientX - ghost.offsetWidth / 2) + 'px';
|
||
ghost.style.top = (ev.clientY - ghost.offsetHeight / 2) + 'px';
|
||
}
|
||
if (ghost) ghost.style.display = 'none';
|
||
const el = document.elementFromPoint(ev.clientX, ev.clientY);
|
||
if (ghost) ghost.style.display = '';
|
||
|
||
const targetSlot = el?.closest('.meal-slot');
|
||
if (targetSlot !== lastTarget) {
|
||
lastTarget?.classList.remove('meal-slot--drop-target');
|
||
if (targetSlot && targetSlot !== dragging.slot) {
|
||
targetSlot.classList.add('meal-slot--drop-target');
|
||
}
|
||
lastTarget = targetSlot;
|
||
}
|
||
}
|
||
|
||
async function onUp(ev) {
|
||
if (!dragging) return;
|
||
const { mealId, sourceDate, sourceType, slot: sourceSlot } = dragging;
|
||
cleanup(); // setzt dragging = null - Werte daher vorher destrukturieren
|
||
|
||
if (ghost) ghost.style.display = 'none';
|
||
const el = document.elementFromPoint(ev.clientX, ev.clientY);
|
||
if (ghost) ghost.style.display = '';
|
||
|
||
const targetSlot = el?.closest('.meal-slot');
|
||
if (targetSlot && targetSlot !== sourceSlot) {
|
||
const targetDate = targetSlot.dataset.date;
|
||
const targetType = targetSlot.dataset.type;
|
||
const targetMealId = targetSlot.dataset.mealId ? parseInt(targetSlot.dataset.mealId, 10) : null;
|
||
_suppressNextClick = true;
|
||
setTimeout(() => { _suppressNextClick = false; }, 300);
|
||
await moveMeal(mealId, sourceDate, sourceType, targetDate, targetType, targetMealId);
|
||
}
|
||
}
|
||
|
||
function onCancel() { cleanup(); }
|
||
|
||
function cleanup() {
|
||
ghost?.remove();
|
||
dragging?.slot?.classList.remove('meal-slot--dragging');
|
||
lastTarget?.classList.remove('meal-slot--drop-target');
|
||
dragging = null;
|
||
card.removeEventListener('pointermove', onMove);
|
||
card.removeEventListener('pointerup', onUp);
|
||
card.removeEventListener('pointercancel', onCancel);
|
||
}
|
||
|
||
card.addEventListener('pointermove', onMove);
|
||
card.addEventListener('pointerup', onUp);
|
||
card.addEventListener('pointercancel', onCancel);
|
||
});
|
||
|
||
// Suppress click after a completed drag
|
||
grid.addEventListener('click', (e) => {
|
||
if (_suppressNextClick) {
|
||
e.stopImmediatePropagation();
|
||
_suppressNextClick = false;
|
||
}
|
||
}, true);
|
||
}
|
||
|
||
async function moveMeal(mealId, sourceDate, sourceType, targetDate, targetType, targetMealId) {
|
||
try {
|
||
if (targetMealId) {
|
||
// Swap: move both meals to each other's slots
|
||
await Promise.all([
|
||
api.put(`/meals/${mealId}`, { date: targetDate, meal_type: targetType }),
|
||
api.put(`/meals/${targetMealId}`, { date: sourceDate, meal_type: sourceType }),
|
||
]);
|
||
const m1 = state.meals.find((m) => m.id === mealId);
|
||
const m2 = state.meals.find((m) => m.id === targetMealId);
|
||
if (m1) { m1.date = targetDate; m1.meal_type = targetType; }
|
||
if (m2) { m2.date = sourceDate; m2.meal_type = sourceType; }
|
||
} else {
|
||
// Move to empty slot
|
||
await api.put(`/meals/${mealId}`, { date: targetDate, meal_type: targetType });
|
||
const m = state.meals.find((m) => m.id === mealId);
|
||
if (m) { m.date = targetDate; m.meal_type = targetType; }
|
||
}
|
||
renderWeekGrid();
|
||
} catch {
|
||
// Re-render to restore visual state
|
||
renderWeekGrid();
|
||
}
|
||
}
|
||
|
||
// --------------------------------------------------------
|
||
// Modal
|
||
// --------------------------------------------------------
|
||
|
||
function openMealModal(opts) {
|
||
state.modal = opts;
|
||
const { mode, date, mealType, meal, presetRecipeId = null } = opts;
|
||
const isEdit = mode === 'edit';
|
||
|
||
const content = buildModalContent(opts);
|
||
|
||
openSharedModal({
|
||
title: isEdit ? t('meals.editMeal') : t('meals.addMealTitle'),
|
||
content,
|
||
size: 'md',
|
||
onSave(panel) {
|
||
// Autocomplete
|
||
const titleInput = panel.querySelector('#modal-title');
|
||
const acDropdown = panel.querySelector('#modal-autocomplete');
|
||
let acIndex = -1;
|
||
let acTimer;
|
||
|
||
titleInput.addEventListener('input', () => {
|
||
clearTimeout(acTimer);
|
||
acTimer = setTimeout(async () => {
|
||
const q = titleInput.value.trim();
|
||
if (!q) { acDropdown.hidden = true; return; }
|
||
try {
|
||
const res = await api.get(`/meals/suggestions?q=${encodeURIComponent(q)}`);
|
||
if (!res.data.length) { acDropdown.hidden = true; return; }
|
||
acIndex = -1;
|
||
acDropdown.innerHTML = res.data.map((s) => `
|
||
<div class="meal-modal__autocomplete-item" data-title="${esc(s.title)}">${esc(s.title)}</div>
|
||
`).join('');
|
||
acDropdown.hidden = false;
|
||
} catch { acDropdown.hidden = true; }
|
||
}, 200);
|
||
});
|
||
|
||
titleInput.addEventListener('keydown', (e) => {
|
||
const items = [...acDropdown.querySelectorAll('.meal-modal__autocomplete-item')];
|
||
if (!items.length) return;
|
||
if (e.key === 'ArrowDown') { e.preventDefault(); acIndex = Math.min(acIndex + 1, items.length - 1); items.forEach((el, i) => el.classList.toggle('meal-modal__autocomplete-item--active', i === acIndex)); }
|
||
if (e.key === 'ArrowUp') { e.preventDefault(); acIndex = Math.max(acIndex - 1, 0); items.forEach((el, i) => el.classList.toggle('meal-modal__autocomplete-item--active', i === acIndex)); }
|
||
if (e.key === 'Enter' && acIndex >= 0) { e.preventDefault(); titleInput.value = items[acIndex].dataset.title; acDropdown.hidden = true; acIndex = -1; }
|
||
if (e.key === 'Escape') acDropdown.hidden = true;
|
||
});
|
||
|
||
acDropdown.addEventListener('mousedown', (e) => {
|
||
const item = e.target.closest('.meal-modal__autocomplete-item');
|
||
if (item) { titleInput.value = item.dataset.title; acDropdown.hidden = true; }
|
||
});
|
||
|
||
// Zutaten
|
||
const ingList = panel.querySelector('#ingredient-list');
|
||
const addIngBtn = panel.querySelector('#add-ingredient-btn');
|
||
const recipeSelect = panel.querySelector('#modal-recipe-id');
|
||
const recipeScaleInput = panel.querySelector('#modal-recipe-scale');
|
||
const saveAsRecipeBtn = panel.querySelector('#modal-save-as-recipe');
|
||
let currentAppliedRecipe = null;
|
||
|
||
const scaleQuantityText = (quantity, factor) => {
|
||
if (!quantity || factor === 1) return quantity;
|
||
|
||
const formatNumber = (num, useComma = false) => {
|
||
const rounded = Math.round(num * 100) / 100;
|
||
if (Number.isInteger(rounded)) return String(rounded);
|
||
const text = String(rounded);
|
||
return useComma ? text.replace('.', ',') : text;
|
||
};
|
||
|
||
const mixed = quantity.match(/^(\d+)\s+(\d+)\/(\d+)(.*)$/);
|
||
if (mixed) {
|
||
const whole = Number(mixed[1]);
|
||
const num = Number(mixed[2]);
|
||
const den = Number(mixed[3]);
|
||
if (den > 0) {
|
||
const value = (whole + (num / den)) * factor;
|
||
return `${formatNumber(value)}${mixed[4]}`;
|
||
}
|
||
}
|
||
|
||
const frac = quantity.match(/^(\d+)\/(\d+)(.*)$/);
|
||
if (frac) {
|
||
const num = Number(frac[1]);
|
||
const den = Number(frac[2]);
|
||
if (den > 0) {
|
||
const value = (num / den) * factor;
|
||
return `${formatNumber(value)}${frac[3]}`;
|
||
}
|
||
}
|
||
|
||
const dec = quantity.match(/^(\d+(?:[.,]\d+)?)(.*)$/);
|
||
if (dec) {
|
||
const useComma = dec[1].includes(',');
|
||
const base = Number(dec[1].replace(',', '.'));
|
||
if (Number.isFinite(base)) {
|
||
return `${formatNumber(base * factor, useComma)}${dec[2]}`;
|
||
}
|
||
}
|
||
|
||
return quantity;
|
||
};
|
||
|
||
const applyRecipe = (recipeId) => {
|
||
const id = Number(recipeId);
|
||
const factor = Math.max(Number(recipeScaleInput?.value || 1), 0.1);
|
||
if (!id) {
|
||
currentAppliedRecipe = null;
|
||
return;
|
||
}
|
||
const recipe = state.recipes.find((r) => r.id === id);
|
||
if (!recipe) return;
|
||
|
||
currentAppliedRecipe = recipe;
|
||
|
||
panel.querySelector('#modal-title').value = recipe.title || '';
|
||
panel.querySelector('#modal-notes').value = recipe.notes || '';
|
||
panel.querySelector('#modal-recipe-url').value = recipe.recipe_url || '';
|
||
panel.querySelector('#modal-meal-category').value = recipe.meal_category || 'other';
|
||
panel.querySelector('#modal-protein').value = recipe.protein || 'mixed';
|
||
panel.querySelector('#modal-style').value = recipe.style || 'family';
|
||
|
||
ingList.innerHTML = (recipe.ingredients || [])
|
||
.map((ing) => {
|
||
const scaledQty = scaleQuantityText(ing.quantity ?? '', factor);
|
||
return ingredientRowHTML(ing.name, scaledQty, null, ing.category ?? DEFAULT_CATEGORY_NAME);
|
||
})
|
||
.join('');
|
||
|
||
if (window.lucide) lucide.createIcons();
|
||
};
|
||
|
||
recipeSelect?.addEventListener('change', () => {
|
||
if (recipeScaleInput) recipeScaleInput.value = '1';
|
||
applyRecipe(recipeSelect.value);
|
||
});
|
||
|
||
recipeScaleInput?.addEventListener('input', () => {
|
||
const currentRecipeId = Number(recipeSelect?.value || 0);
|
||
if (!currentRecipeId || !currentAppliedRecipe) return;
|
||
|
||
const factor = Number(recipeScaleInput.value || 1);
|
||
if (!Number.isFinite(factor) || factor <= 0) return;
|
||
|
||
ingList.innerHTML = (currentAppliedRecipe.ingredients || [])
|
||
.map((ing) => ingredientRowHTML(
|
||
ing.name,
|
||
scaleQuantityText(ing.quantity ?? '', Math.max(factor, 0.1)),
|
||
null,
|
||
ing.category ?? DEFAULT_CATEGORY_NAME
|
||
))
|
||
.join('');
|
||
|
||
if (window.lucide) lucide.createIcons();
|
||
});
|
||
|
||
panel.querySelector('#modal-leftover-from-meal-id')?.addEventListener('change', (event) => {
|
||
if (!event.target.value) return;
|
||
panel.querySelector('#modal-meal-category').value = 'leftovers';
|
||
panel.querySelector('#modal-protein').value = 'none';
|
||
panel.querySelector('#modal-style').value = 'leftovers';
|
||
});
|
||
|
||
panel.querySelector('[data-meal-recipe-pref-actions]')?.addEventListener('click', async (e) => {
|
||
const btn = e.target.closest('[data-meal-recipe-pref]');
|
||
if (!btn) return;
|
||
const recipeId = Number(panel.querySelector('#modal-recipe-id')?.value || 0);
|
||
const userId = Number(panel.querySelector('#modal-recipe-pref-member')?.value || 0);
|
||
if (!recipeId || !userId) {
|
||
window.oikos?.showToast('Choose a saved recipe and family member first.', 'error');
|
||
return;
|
||
}
|
||
const kind = btn.dataset.mealRecipePref;
|
||
const patch = kind === 'favorite' ? { preference: 'favorite' }
|
||
: kind === 'like' ? { preference: 'like' }
|
||
: kind === 'dislike' ? { preference: 'dislike' }
|
||
: kind === 'canCook' ? { can_cook: true }
|
||
: {};
|
||
btn.disabled = true;
|
||
try {
|
||
await saveRecipeSignal(recipeId, userId, patch);
|
||
window.oikos?.showToast('Meal signal saved to profile', 'success');
|
||
} catch (err) {
|
||
window.oikos?.showToast(err.data?.error ?? t('common.errorGeneric'), 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
|
||
saveAsRecipeBtn?.addEventListener('click', async () => {
|
||
const title = panel.querySelector('#modal-title').value.trim();
|
||
if (!title) {
|
||
window.oikos?.showToast(t('meals.titleRequired'), 'error');
|
||
return;
|
||
}
|
||
|
||
const notes = panel.querySelector('#modal-notes').value.trim() || null;
|
||
const recipe_url = panel.querySelector('#modal-recipe-url').value.trim() || null;
|
||
const ingredients = collectModalIngredients(panel).map((ing) => ({
|
||
name: ing.name,
|
||
quantity: ing.quantity,
|
||
category: ing.category,
|
||
}));
|
||
const meal_category = panel.querySelector('#modal-meal-category')?.value || 'other';
|
||
const protein = panel.querySelector('#modal-protein')?.value || 'mixed';
|
||
const style = panel.querySelector('#modal-style')?.value || 'family';
|
||
|
||
saveAsRecipeBtn.disabled = true;
|
||
try {
|
||
const created = await api.post('/recipes', { title, notes, recipe_url, meal_category, protein, style, ingredients });
|
||
state.recipes.push(created.data);
|
||
|
||
if (recipeSelect) {
|
||
const option = document.createElement('option');
|
||
option.value = String(created.data.id);
|
||
option.textContent = created.data.title;
|
||
recipeSelect.appendChild(option);
|
||
recipeSelect.value = String(created.data.id);
|
||
}
|
||
|
||
window.oikos?.showToast(t('recipes.created'), 'success');
|
||
} catch (err) {
|
||
window.oikos?.showToast(err.data?.error ?? t('common.errorGeneric'), 'error');
|
||
} finally {
|
||
saveAsRecipeBtn.disabled = false;
|
||
}
|
||
});
|
||
|
||
if (presetRecipeId && recipeSelect) {
|
||
recipeSelect.value = String(presetRecipeId);
|
||
applyRecipe(presetRecipeId);
|
||
}
|
||
panel.querySelectorAll('.js-date-input').forEach((input) => {
|
||
input.addEventListener('blur', () => {
|
||
const parsed = parseDateInput(input.value);
|
||
if (parsed) input.value = formatDateInput(parsed);
|
||
});
|
||
});
|
||
|
||
addIngBtn.addEventListener('click', () => {
|
||
const tmp = document.createElement('div');
|
||
tmp.innerHTML = ingredientRowHTML('', '', null);
|
||
const row = tmp.firstElementChild;
|
||
ingList.appendChild(row);
|
||
if (window.lucide) lucide.createIcons();
|
||
row.querySelector('input').focus();
|
||
});
|
||
|
||
ingList.addEventListener('click', (e) => {
|
||
const btn = e.target.closest('[data-action="remove-ingredient"]');
|
||
if (btn) btn.closest('.ingredient-row').remove();
|
||
});
|
||
|
||
// Einkaufslisten-Transfer
|
||
panel.querySelector('#transfer-btn')?.addEventListener('click', async () => {
|
||
const selectEl = panel.querySelector('#transfer-list-select');
|
||
const listId = parseInt(selectEl?.value, 10);
|
||
if (!listId || !state.modal?.meal) return;
|
||
const btn = panel.querySelector('#transfer-btn');
|
||
btn.disabled = true;
|
||
try {
|
||
const res = await api.post(`/meals/${state.modal.meal.id}/to-shopping-list`, { listId });
|
||
if (res.data.transferred > 0) {
|
||
window.oikos?.showToast(res.data.transferred !== 1 ? t('meals.transferSuccessPlural', { count: res.data.transferred }) : t('meals.transferSuccess', { count: res.data.transferred }), 'success');
|
||
await loadWeek(state.currentWeek);
|
||
closeModal({ force: true });
|
||
renderWeekGrid();
|
||
} else {
|
||
window.oikos?.showToast(t('meals.transferAlreadyDone'), 'info');
|
||
btn.disabled = false;
|
||
}
|
||
} catch (err) {
|
||
window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error');
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
|
||
panel.querySelector('#modal-cancel').addEventListener('click', closeModal);
|
||
panel.querySelector('#modal-save').addEventListener('click', () => saveModal(panel));
|
||
},
|
||
});
|
||
}
|
||
|
||
function buildModalContent({ mode, date, mealType, meal }) {
|
||
const isEdit = mode === 'edit';
|
||
const typeOpts = MEAL_TYPES().map((mt) =>
|
||
`<option value="${mt.key}" ${mt.key === mealType ? 'selected' : ''}>${mt.label}</option>`
|
||
).join('');
|
||
|
||
const listOpts = state.lists.length
|
||
? state.lists.map((l) => `<option value="${l.id}">${esc(l.name)}</option>`).join('')
|
||
: `<option value="" disabled>${t('meals.noShoppingLists')}</option>`;
|
||
|
||
const ingRows = isEdit && meal.ingredients?.length
|
||
? meal.ingredients.map((ing) => ingredientRowHTML(ing.name, ing.quantity ?? '', ing.id, ing.category ?? DEFAULT_CATEGORY_NAME)).join('')
|
||
: '';
|
||
|
||
const hasIngOpen = isEdit && meal.ingredients?.some((i) => !i.on_shopping_list);
|
||
|
||
const recipeOptions = [
|
||
`<option value="">${t('meals.savedRecipePlaceholder')}</option>`,
|
||
...state.recipes.map((r) => `<option value="${r.id}" ${isEdit && meal.recipe_id === r.id ? 'selected' : ''}>${esc(r.title)}</option>`),
|
||
].join('');
|
||
|
||
const selectedCookId = isEdit && meal.cook_assignment?.user_id ? String(meal.cook_assignment.user_id) : '';
|
||
const cookOptions = [
|
||
`<option value="">${t('meals.cookNone')}</option>`,
|
||
...state.familyMembers.map((member) => `<option value="${member.id}" ${selectedCookId === String(member.id) ? 'selected' : ''}>${esc(member.display_name)}</option>`),
|
||
].join('');
|
||
|
||
const leftoverOptions = [
|
||
`<option value="">Ingen rester</option>`,
|
||
...state.meals
|
||
.filter((candidate) => !isEdit || candidate.id !== meal.id)
|
||
.slice(-20)
|
||
.map((candidate) => `<option value="${candidate.id}" ${isEdit && meal.leftover_from_meal_id === candidate.id ? 'selected' : ''}>${esc(candidate.date)} · ${esc(candidate.title)}</option>`),
|
||
].join('');
|
||
|
||
return `
|
||
<div class="modal-grid modal-grid--2">
|
||
<div class="form-group">
|
||
<label class="form-label" for="modal-date">${t('meals.dateLabel')}</label>
|
||
<input type="text" class="form-input js-date-input" id="modal-date" value="${formatDateInput(date)}" placeholder="${dateInputPlaceholder()}" inputmode="numeric">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label" for="modal-type">${t('meals.mealTypeLabel')}</label>
|
||
<select class="form-input" id="modal-type">${typeOpts}</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label" for="modal-cook-user-id">${t('meals.cookLabel')}</label>
|
||
<select class="form-input" id="modal-cook-user-id">${cookOptions}</select>
|
||
</div>
|
||
|
||
<div class="form-group" style="position:relative;">
|
||
<label class="form-label" for="modal-title">${t('meals.titleLabel')}</label>
|
||
<input type="text" class="form-input" id="modal-title"
|
||
placeholder="${t('meals.titlePlaceholder')}"
|
||
value="${esc(isEdit ? meal.title : '')}"
|
||
autocomplete="off">
|
||
<div id="modal-autocomplete" class="meal-modal__autocomplete" hidden></div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label" for="modal-recipe-id">${t('meals.savedRecipeLabel')}</label>
|
||
<select class="form-input" id="modal-recipe-id">${recipeOptions}</select>
|
||
</div>
|
||
|
||
<div class="form-group" data-meal-recipe-pref-actions>
|
||
<label class="form-label" for="modal-recipe-pref-member">Save this meal as a person-specific signal</label>
|
||
<select class="form-input" id="modal-recipe-pref-member">
|
||
<option value="">Choose family member</option>
|
||
${state.familyMembers.map((member) => `<option value="${member.id}">${esc(member.display_name)}</option>`).join('')}
|
||
</select>
|
||
<div class="recipe-card__actions" style="margin-top:var(--space-2)">
|
||
<button type="button" class="btn btn--ghost" data-meal-recipe-pref="favorite">⭐ Favorite</button>
|
||
<button type="button" class="btn btn--ghost" data-meal-recipe-pref="like">👍 Likes</button>
|
||
<button type="button" class="btn btn--ghost" data-meal-recipe-pref="dislike">👎 Dislikes</button>
|
||
<button type="button" class="btn btn--ghost" data-meal-recipe-pref="canCook">👩🍳 Can cook</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-grid modal-grid--3">
|
||
<div class="form-group">
|
||
<label class="form-label" for="modal-meal-category">Meal category</label>
|
||
<select class="form-input" id="modal-meal-category">${optionHtml(MEAL_CATEGORY_OPTIONS, isEdit ? (meal.meal_category || 'other') : 'other')}</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label" for="modal-protein">Protein</label>
|
||
<select class="form-input" id="modal-protein">${optionHtml(PROTEIN_OPTIONS, isEdit ? (meal.protein || 'mixed') : 'mixed')}</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label" for="modal-style">Style</label>
|
||
<select class="form-input" id="modal-style">${optionHtml(STYLE_OPTIONS, isEdit ? (meal.style || 'family') : 'family')}</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label" for="modal-leftover-from-meal-id">Use leftovers from a specific dish</label>
|
||
<select class="form-input" id="modal-leftover-from-meal-id">${leftoverOptions}</select>
|
||
</div>
|
||
|
||
<div class="modal-grid modal-grid--2">
|
||
<div class="form-group">
|
||
<label class="form-label" for="modal-recipe-scale">${t('meals.recipeScaleLabel')}</label>
|
||
<input type="number" class="form-input" id="modal-recipe-scale" min="0.1" step="0.1" value="1">
|
||
</div>
|
||
<div class="form-group" style="display:flex;align-items:flex-end;">
|
||
<button class="btn btn--secondary" id="modal-save-as-recipe" type="button">${t('meals.saveAsRecipe')}</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label" for="modal-notes">${t('meals.notesLabel')}</label>
|
||
<textarea class="form-input" id="modal-notes" rows="2"
|
||
placeholder="${t('meals.notesPlaceholder')}">${esc(isEdit && meal.notes ? meal.notes : '')}</textarea>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label" for="modal-recipe-url">${t('meals.recipeUrlLabel')}</label>
|
||
<input type="url" class="form-input" id="modal-recipe-url"
|
||
placeholder="${t('meals.recipeUrlPlaceholder')}"
|
||
value="${esc(isEdit && meal.recipe_url ? meal.recipe_url : '')}">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">${t('meals.ingredientsLabel')}</label>
|
||
<div class="ingredient-list" id="ingredient-list">${ingRows}</div>
|
||
<button class="add-ingredient-btn" id="add-ingredient-btn" type="button">
|
||
<i data-lucide="plus" style="width:14px;height:14px;" aria-hidden="true"></i>
|
||
${t('meals.addIngredient')}
|
||
</button>
|
||
</div>
|
||
|
||
${isEdit && hasIngOpen ? `
|
||
<div class="shopping-transfer">
|
||
<div class="shopping-transfer__label">
|
||
<i data-lucide="shopping-cart" style="width:14px;height:14px;" aria-hidden="true"></i>
|
||
${t('meals.transferLabel')}
|
||
</div>
|
||
<select class="shopping-transfer__select" id="transfer-list-select">${listOpts}</select>
|
||
<button class="btn btn--secondary shopping-transfer__btn" id="transfer-btn" type="button">
|
||
${t('meals.transferNow')}
|
||
</button>
|
||
</div>` : ''}
|
||
|
||
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
|
||
<button class="btn btn--secondary" id="modal-cancel">${t('common.cancel')}</button>
|
||
<button class="btn btn--primary" id="modal-save">${isEdit ? t('common.save') : t('common.add')}</button>
|
||
</div>`;
|
||
}
|
||
|
||
function ingredientRowHTML(name, qty, id, category = DEFAULT_CATEGORY_NAME) {
|
||
const availableCategories = mealCategories();
|
||
const resolvedCategory = availableCategories.some((c) => c.name === category)
|
||
? category
|
||
: (availableCategories[0]?.name ?? DEFAULT_CATEGORY_NAME);
|
||
const catOptions = availableCategories.length
|
||
? availableCategories.map((c) => `<option value="${esc(c.name)}" ${c.name === resolvedCategory ? 'selected' : ''}>${esc(categoryLabel(c.name))}</option>`).join('')
|
||
: `<option value="${DEFAULT_CATEGORY_NAME}" selected>${t('meals.ingredientCategoryDefault')}</option>`;
|
||
|
||
return `
|
||
<div class="ingredient-row" data-ing-id="${id ?? ''}">
|
||
<input type="text" class="form-input ingredient-row__name" placeholder="${t('meals.ingredientNamePlaceholder')}" value="${esc(name)}">
|
||
<input type="text" class="form-input ingredient-row__qty" placeholder="${t('meals.ingredientQtyPlaceholder')}" value="${esc(qty)}">
|
||
<select class="form-input ingredient-row__cat" aria-label="${t('meals.ingredientCategoryLabel')}">${catOptions}</select>
|
||
<button class="ingredient-row__remove" data-action="remove-ingredient" type="button" aria-label="${t('meals.removeIngredient')}">
|
||
<i data-lucide="x" style="width:14px;height:14px;" aria-hidden="true"></i>
|
||
</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function closeModal({ force = false } = {}) {
|
||
closeSharedModal({ force });
|
||
state.modal = null;
|
||
}
|
||
|
||
async function saveModal(overlay) {
|
||
const saveBtn = overlay.querySelector('#modal-save');
|
||
const dateRaw = overlay.querySelector('#modal-date').value;
|
||
const date = parseDateInput(dateRaw);
|
||
const meal_type = overlay.querySelector('#modal-type').value;
|
||
const title = overlay.querySelector('#modal-title').value.trim();
|
||
const notes = overlay.querySelector('#modal-notes').value.trim() || null;
|
||
const recipe_url = overlay.querySelector('#modal-recipe-url').value.trim() || null;
|
||
const recipe_id = overlay.querySelector('#modal-recipe-id')?.value || null;
|
||
const meal_category = overlay.querySelector('#modal-meal-category')?.value || 'other';
|
||
const protein = overlay.querySelector('#modal-protein')?.value || 'mixed';
|
||
const style = overlay.querySelector('#modal-style')?.value || 'family';
|
||
const leftover_from_meal_id = overlay.querySelector('#modal-leftover-from-meal-id')?.value || null;
|
||
const cookSelect = overlay.querySelector('#modal-cook-user-id');
|
||
const cook_user_id = cookSelect?.value ? Number(cookSelect.value) : null;
|
||
|
||
if (!date || !isDateInputValid(dateRaw)) {
|
||
window.oikos?.showToast(t('calendar.invalidDate'), 'error');
|
||
return;
|
||
}
|
||
|
||
if (!title) {
|
||
window.oikos?.showToast(t('meals.titleRequired'), 'error');
|
||
return;
|
||
}
|
||
|
||
const ingredients = collectModalIngredients(overlay);
|
||
|
||
saveBtn.disabled = true;
|
||
saveBtn.textContent = '…';
|
||
|
||
try {
|
||
const { mode, meal } = state.modal;
|
||
|
||
const mealPayload = { date, meal_type, title, notes, recipe_url, recipe_id, meal_category, protein, style, leftover_from_meal_id, cook_user_id };
|
||
|
||
if (mode === 'create') {
|
||
const res = await api.post('/meals', { ...mealPayload, ingredients });
|
||
state.meals.push(res.data);
|
||
} else {
|
||
// Update meal meta
|
||
await api.put(`/meals/${meal.id}`, mealPayload);
|
||
|
||
// Sync ingredients
|
||
const existingIds = new Set((meal.ingredients ?? []).map((i) => i.id));
|
||
const keptIds = new Set(
|
||
ingredients.filter((i) => i.id).map((i) => parseInt(i.id, 10))
|
||
);
|
||
|
||
for (const id of existingIds) {
|
||
if (!keptIds.has(id)) await api.delete(`/meals/ingredients/${id}`);
|
||
}
|
||
for (const ing of ingredients) {
|
||
if (!ing.id) await api.post(`/meals/${meal.id}/ingredients`, { name: ing.name, quantity: ing.quantity, category: ing.category });
|
||
}
|
||
|
||
// Reload updated meal
|
||
await loadWeek(state.currentWeek);
|
||
}
|
||
|
||
closeModal({ force: true });
|
||
renderWeekGrid();
|
||
window.oikos?.showToast(mode === 'create' ? t('meals.addMealTitle') : t('meals.editMeal'), 'success');
|
||
} catch (err) {
|
||
window.oikos?.showToast(err.data?.error ?? t('common.errorGeneric'), 'error');
|
||
saveBtn.disabled = false;
|
||
saveBtn.textContent = state.modal?.mode === 'edit' ? t('common.save') : t('common.add');
|
||
}
|
||
}
|
||
|
||
function collectModalIngredients(overlay) {
|
||
const ingredients = [];
|
||
overlay.querySelectorAll('.ingredient-row').forEach((row) => {
|
||
const name = row.querySelector('.ingredient-row__name').value.trim();
|
||
const qty = row.querySelector('.ingredient-row__qty').value.trim() || null;
|
||
const category = row.querySelector('.ingredient-row__cat')?.value || DEFAULT_CATEGORY_NAME;
|
||
if (name) ingredients.push({ name, quantity: qty, category, id: row.dataset.ingId || null });
|
||
});
|
||
return ingredients;
|
||
}
|
||
|
||
// --------------------------------------------------------
|
||
// Mahlzeit löschen
|
||
// --------------------------------------------------------
|
||
|
||
async function deleteMeal(mealId) {
|
||
const meal = state.meals.find((m) => m.id === mealId);
|
||
const itemEl = _container.querySelector(`.meal-slot--has-meal[data-meal-id="${mealId}"]`);
|
||
if (itemEl) itemEl.style.display = 'none';
|
||
|
||
let undone = false;
|
||
window.oikos?.showToast(t('meals.deletedToast'), 'default', 5000, () => {
|
||
undone = true;
|
||
if (itemEl) itemEl.style.display = '';
|
||
});
|
||
|
||
setTimeout(async () => {
|
||
if (undone) return;
|
||
try {
|
||
await api.delete(`/meals/${mealId}`);
|
||
state.meals = state.meals.filter((m) => m.id !== mealId);
|
||
renderWeekGrid();
|
||
} catch (err) {
|
||
if (itemEl) itemEl.style.display = '';
|
||
window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'danger');
|
||
}
|
||
}, 5000);
|
||
}
|
||
|
||
// --------------------------------------------------------
|
||
// Zutaten → Einkaufsliste (Quick-Transfer vom Slot aus)
|
||
// --------------------------------------------------------
|
||
|
||
async function transferMeal(mealId) {
|
||
if (!state.lists.length) {
|
||
window.oikos?.showToast(t('meals.noShoppingLists'), 'error');
|
||
return;
|
||
}
|
||
|
||
let listId = state.lists[0].id;
|
||
|
||
if (state.lists.length > 1) {
|
||
const options = state.lists.map((l) => ({ value: l.id, label: l.name }));
|
||
const choice = await selectModal(t('meals.transferToShoppingList'), options);
|
||
if (choice === null) return;
|
||
listId = Number(choice);
|
||
}
|
||
|
||
try {
|
||
const res = await api.post(`/meals/${mealId}/to-shopping-list`, { listId });
|
||
if (res.data.transferred > 0) {
|
||
window.oikos?.showToast(res.data.transferred !== 1 ? t('meals.transferSuccessPlural', { count: res.data.transferred }) : t('meals.transferSuccess', { count: res.data.transferred }), 'success');
|
||
await loadWeek(state.currentWeek);
|
||
renderWeekGrid();
|
||
} else {
|
||
window.oikos?.showToast(t('meals.transferAlreadyDone'), 'info');
|
||
}
|
||
} catch (err) {
|
||
window.oikos?.showToast(err.data?.error ?? t('common.errorGeneric'), 'error');
|
||
}
|
||
}
|
||
|
||
// --------------------------------------------------------
|
||
// Hilfsfunktion
|
||
// --------------------------------------------------------
|