feat: Phase 2 Schritt 11+12 — Essensplan-Modul + Einkaufslisten-Integration
- server/routes/meals.js: vollständige REST-API (GET Woche, POST/PUT/DELETE Mahlzeit, POST/PATCH/DELETE Zutaten, GET Autocomplete-Suggestions, POST to-shopping-list, POST week-to-shopping-list) - public/pages/meals.js: Wochengitter (Mo–So × 4 Mahlzeit-Typen), Navigations-Buttons, CRUD-Modal mit Autocomplete, Zutaten-Verwaltung, Einkaufslisten-Transfer-Button - public/styles/meals.css: Wochengitter, Slot-Karten, Modal-Overlay, Zutaten-Zeilen, Transfer-Panel, Typ-Farben - test-meals.js: 22 Tests (CRUD, Wochensortierung, Constraint, CASCADE, Integration, Autocomplete, Wochenberechnung) - package.json: test:meals + Gesamt-Test-Suite erweitert - public/index.html: meals.css eingebunden Gesamt: 93 Tests bestanden (29 DB + 8 Dashboard + 17 Tasks + 17 Shopping + 22 Meals) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -11,7 +11,8 @@
|
|||||||
"test:dashboard": "node --experimental-sqlite test-dashboard.js",
|
"test:dashboard": "node --experimental-sqlite test-dashboard.js",
|
||||||
"test:tasks": "node --experimental-sqlite test-tasks.js",
|
"test:tasks": "node --experimental-sqlite test-tasks.js",
|
||||||
"test:shopping": "node --experimental-sqlite test-shopping.js",
|
"test:shopping": "node --experimental-sqlite test-shopping.js",
|
||||||
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js"
|
"test:meals": "node --experimental-sqlite test-meals.js",
|
||||||
|
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
<link rel="stylesheet" href="/styles/dashboard.css" />
|
<link rel="stylesheet" href="/styles/dashboard.css" />
|
||||||
<link rel="stylesheet" href="/styles/tasks.css" />
|
<link rel="stylesheet" href="/styles/tasks.css" />
|
||||||
<link rel="stylesheet" href="/styles/shopping.css" />
|
<link rel="stylesheet" href="/styles/shopping.css" />
|
||||||
|
<link rel="stylesheet" href="/styles/meals.css" />
|
||||||
|
|
||||||
<!-- Lucide Icons (CDN) -->
|
<!-- Lucide Icons (CDN) -->
|
||||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||||
|
|||||||
+580
-13
@@ -1,25 +1,592 @@
|
|||||||
/**
|
/**
|
||||||
* Modul: Meals
|
* Modul: Essensplan (Meals)
|
||||||
* Zweck: Seite für das Meals-Modul
|
* Zweck: Wochenansicht mit Mahlzeit-CRUD, Zutaten-Verwaltung und Einkaufslisten-Integration
|
||||||
* Abhängigkeiten: /api.js
|
* Abhängigkeiten: /api.js, /router.js (window.oikos)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { api } from '/api.js';
|
import { api } from '/api.js';
|
||||||
|
|
||||||
/**
|
// --------------------------------------------------------
|
||||||
* @param {HTMLElement} container
|
// Konstanten
|
||||||
* @param {{ user: object }} context
|
// --------------------------------------------------------
|
||||||
*/
|
|
||||||
|
const MEAL_TYPES = [
|
||||||
|
{ key: 'breakfast', label: 'Frühstück', icon: 'sunrise' },
|
||||||
|
{ key: 'lunch', label: 'Mittagessen', icon: 'sun' },
|
||||||
|
{ key: 'dinner', label: 'Abendessen', icon: 'moon' },
|
||||||
|
{ key: 'snack', label: 'Snack', icon: 'cookie' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DAY_NAMES = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// State
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
let state = {
|
||||||
|
currentWeek: null, // YYYY-MM-DD (Montag)
|
||||||
|
meals: [],
|
||||||
|
lists: [], // Einkaufslisten für Transfer-Dropdown
|
||||||
|
modal: 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);
|
||||||
|
const fmt = (s) => {
|
||||||
|
const d = new Date(s + 'T00:00:00Z');
|
||||||
|
return `${d.getUTCDate().toString().padStart(2, '0')}.${(d.getUTCMonth() + 1).toString().padStart(2, '0')}.${d.getUTCFullYear()}`;
|
||||||
|
};
|
||||||
|
return `${fmt(monday)} – ${fmt(sunday)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToday(dateStr) {
|
||||||
|
return dateStr === new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDayDate(dateStr) {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00Z');
|
||||||
|
return `${d.getUTCDate()}.${d.getUTCMonth() + 1}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// API-Wrapper
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
async function loadWeek(week) {
|
||||||
|
const res = await api.get(`/meals?week=${week}`);
|
||||||
|
state.meals = res.data;
|
||||||
|
state.currentWeek = getMondayOf(week);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLists() {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/shopping');
|
||||||
|
state.lists = res.data;
|
||||||
|
} catch {
|
||||||
|
state.lists = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Render
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
export async function render(container, { user }) {
|
export async function render(container, { user }) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="page">
|
<div class="meals-page">
|
||||||
<div class="page__header">
|
<div class="week-nav">
|
||||||
<h1 class="page__title">Meals</h1>
|
<button class="btn btn--icon" id="week-prev" aria-label="Vorherige Woche">
|
||||||
|
<i data-lucide="chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
<span class="week-nav__label" id="week-label"></span>
|
||||||
|
<button class="week-nav__today" id="week-today">Heute</button>
|
||||||
|
<button class="btn btn--icon" id="week-next" aria-label="Nächste Woche">
|
||||||
|
<i data-lucide="chevron-right"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="empty-state">
|
<div class="week-grid" id="week-grid">
|
||||||
<div class="empty-state__title">Kommt bald.</div>
|
<div style="margin:auto;padding:2rem;text-align:center;color:var(--color-text-disabled)">Lade…</div>
|
||||||
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const monday = getMondayOf(today);
|
||||||
|
|
||||||
|
await Promise.all([loadWeek(monday), loadLists()]);
|
||||||
|
renderWeekGrid();
|
||||||
|
wireNav();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Wochengitter
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
function renderWeekGrid() {
|
||||||
|
const grid = document.getElementById('week-grid');
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
document.getElementById('week-label').textContent =
|
||||||
|
formatWeekLabel(state.currentWeek);
|
||||||
|
|
||||||
|
const days = Array.from({ length: 7 }, (_, i) => addDays(state.currentWeek, i));
|
||||||
|
|
||||||
|
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">${DAY_NAMES[idx]}</span>
|
||||||
|
<span class="day-header__date">${formatDayDate(date)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="day-slots">
|
||||||
|
${MEAL_TYPES.map((type) => renderSlot(date, type, mealsForDay)).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
wireGrid(grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSlot(date, type, mealsForDay) {
|
||||||
|
const meal = mealsForDay.find((m) => m.meal_type === type.key);
|
||||||
|
|
||||||
|
if (!meal) {
|
||||||
|
return `
|
||||||
|
<div class="meal-slot" data-date="${date}" data-type="${type.key}">
|
||||||
|
<div class="meal-slot__type-label">${type.label}</div>
|
||||||
|
<button
|
||||||
|
class="meal-slot__add-btn"
|
||||||
|
data-action="add-meal"
|
||||||
|
data-date="${date}"
|
||||||
|
data-type="${type.key}"
|
||||||
|
aria-label="${type.label} hinzufügen"
|
||||||
|
>
|
||||||
|
<i data-lucide="plus" style="width:16px;height:16px;"></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} Zutat${ingCount !== 1 ? 'en' : ''}` : '';
|
||||||
|
const ingDoneLabel = ingCount > 0 && ingDone === ingCount ? ' ✓' : '';
|
||||||
|
const canTransfer = ingCount > 0 && ingDone < ingCount;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="meal-slot meal-slot--has-meal" data-meal-id="${meal.id}" 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">${escHtml(meal.title)}</div>
|
||||||
|
${ingLabel ? `<div class="meal-card__meta">
|
||||||
|
<span class="meal-card__ingredients-count">${ingLabel}${escHtml(ingDoneLabel)}</span>
|
||||||
|
</div>` : ''}
|
||||||
|
<div class="meal-card__actions">
|
||||||
|
${canTransfer ? `<button class="meal-card__action-btn meal-card__action-btn--shopping"
|
||||||
|
data-action="transfer-meal"
|
||||||
|
data-meal-id="${meal.id}"
|
||||||
|
title="Zutaten auf Einkaufsliste"
|
||||||
|
><i data-lucide="shopping-cart" style="width:14px;height:14px;"></i></button>` : ''}
|
||||||
|
<button class="meal-card__action-btn"
|
||||||
|
data-action="delete-meal"
|
||||||
|
data-meal-id="${meal.id}"
|
||||||
|
title="Löschen"
|
||||||
|
><i data-lucide="trash-2" style="width:14px;height:14px;"></i></button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Event-Delegation
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
function wireNav() {
|
||||||
|
document.getElementById('week-prev')?.addEventListener('click', async () => {
|
||||||
|
await loadWeek(addDays(state.currentWeek, -7));
|
||||||
|
renderWeekGrid();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('week-next')?.addEventListener('click', async () => {
|
||||||
|
await loadWeek(addDays(state.currentWeek, 7));
|
||||||
|
renderWeekGrid();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('week-today')?.addEventListener('click', async () => {
|
||||||
|
const monday = getMondayOf(new Date().toISOString().slice(0, 10));
|
||||||
|
if (monday === state.currentWeek) return;
|
||||||
|
await loadWeek(monday);
|
||||||
|
renderWeekGrid();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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') {
|
||||||
|
openModal({ mode: 'create', date: btn.dataset.date, mealType: btn.dataset.type });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'edit-meal') {
|
||||||
|
const mealId = parseInt(btn.dataset.mealId, 10);
|
||||||
|
const meal = state.meals.find((m) => m.id === mealId);
|
||||||
|
if (meal) openModal({ 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(); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Modal
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
function openModal(opts) {
|
||||||
|
state.modal = opts;
|
||||||
|
document.getElementById('meal-modal-overlay')?.remove();
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'meal-modal-overlay';
|
||||||
|
overlay.className = 'meal-modal-overlay';
|
||||||
|
overlay.innerHTML = buildModalHTML(opts);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
|
||||||
|
// Autocomplete
|
||||||
|
const titleInput = overlay.querySelector('#modal-title');
|
||||||
|
const acDropdown = overlay.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="${escHtml(s.title)}">${escHtml(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 = overlay.querySelector('#ingredient-list');
|
||||||
|
const addIngBtn = overlay.querySelector('#add-ingredient-btn');
|
||||||
|
|
||||||
|
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 Button im Modal
|
||||||
|
overlay.querySelector('#transfer-btn')?.addEventListener('click', async () => {
|
||||||
|
const selectEl = overlay.querySelector('#transfer-list-select');
|
||||||
|
const listId = parseInt(selectEl?.value, 10);
|
||||||
|
if (!listId || !state.modal?.meal) return;
|
||||||
|
const btn = overlay.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} Zutat${res.data.transferred !== 1 ? 'en' : ''} übertragen`, 'success');
|
||||||
|
await loadWeek(state.currentWeek);
|
||||||
|
closeModal();
|
||||||
|
renderWeekGrid();
|
||||||
|
} else {
|
||||||
|
window.oikos?.showToast('Alle Zutaten bereits übertragen', 'info');
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schließen
|
||||||
|
overlay.querySelector('#modal-close').addEventListener('click', closeModal);
|
||||||
|
overlay.querySelector('#modal-cancel').addEventListener('click', closeModal);
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeModal(); });
|
||||||
|
|
||||||
|
// Speichern
|
||||||
|
overlay.querySelector('#modal-save').addEventListener('click', () => saveModal(overlay));
|
||||||
|
|
||||||
|
titleInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildModalHTML({ mode, date, mealType, meal }) {
|
||||||
|
const isEdit = mode === 'edit';
|
||||||
|
const typeOpts = MEAL_TYPES.map((t) =>
|
||||||
|
`<option value="${t.key}" ${t.key === mealType ? 'selected' : ''}>${t.label}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const listOpts = state.lists.length
|
||||||
|
? state.lists.map((l) => `<option value="${l.id}">${escHtml(l.name)}</option>`).join('')
|
||||||
|
: '<option value="" disabled>Keine Einkaufslisten vorhanden</option>';
|
||||||
|
|
||||||
|
const ingRows = isEdit && meal.ingredients?.length
|
||||||
|
? meal.ingredients.map((ing) => ingredientRowHTML(ing.name, ing.quantity ?? '', ing.id)).join('')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const hasIngOpen = isEdit && meal.ingredients?.some((i) => !i.on_shopping_list);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="meal-modal" role="dialog" aria-modal="true">
|
||||||
|
<div class="meal-modal__header">
|
||||||
|
<h2 class="meal-modal__title">${isEdit ? 'Mahlzeit bearbeiten' : 'Mahlzeit hinzufügen'}</h2>
|
||||||
|
<button class="meal-modal__close" id="modal-close" aria-label="Schließen">
|
||||||
|
<i data-lucide="x" style="width:16px;height:16px;"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="meal-modal__body">
|
||||||
|
<div class="meal-modal__row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="modal-date">Datum</label>
|
||||||
|
<input type="date" class="form-input" id="modal-date" value="${date}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="modal-type">Mahlzeit</label>
|
||||||
|
<select class="form-input" id="modal-type">${typeOpts}</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="position:relative;">
|
||||||
|
<label class="form-label" for="modal-title">Titel *</label>
|
||||||
|
<input type="text" class="form-input" id="modal-title"
|
||||||
|
placeholder="z.B. Spaghetti Bolognese"
|
||||||
|
value="${escHtml(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-notes">Notizen</label>
|
||||||
|
<textarea class="form-input" id="modal-notes" rows="2"
|
||||||
|
placeholder="Optional…">${escHtml(isEdit && meal.notes ? meal.notes : '')}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Zutaten</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;"></i>
|
||||||
|
Zutat hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${isEdit && hasIngOpen ? `
|
||||||
|
<div class="shopping-transfer">
|
||||||
|
<div class="shopping-transfer__label">
|
||||||
|
<i data-lucide="shopping-cart" style="width:14px;height:14px;"></i>
|
||||||
|
Zutaten auf Einkaufsliste übertragen
|
||||||
|
</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">
|
||||||
|
Jetzt übertragen
|
||||||
|
</button>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="meal-modal__footer">
|
||||||
|
<button class="btn btn--secondary" id="modal-cancel">Abbrechen</button>
|
||||||
|
<button class="btn btn--primary" id="modal-save">${isEdit ? 'Speichern' : 'Hinzufügen'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ingredientRowHTML(name, qty, id) {
|
||||||
|
return `
|
||||||
|
<div class="ingredient-row" data-ing-id="${id ?? ''}">
|
||||||
|
<input type="text" class="form-input ingredient-row__name" placeholder="Zutat" value="${escHtml(name)}">
|
||||||
|
<input type="text" class="form-input ingredient-row__qty" placeholder="Menge" value="${escHtml(qty)}">
|
||||||
|
<button class="ingredient-row__remove" data-action="remove-ingredient" type="button" title="Entfernen">
|
||||||
|
<i data-lucide="x" style="width:14px;height:14px;"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('meal-modal-overlay')?.remove();
|
||||||
|
state.modal = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveModal(overlay) {
|
||||||
|
const saveBtn = overlay.querySelector('#modal-save');
|
||||||
|
const date = overlay.querySelector('#modal-date').value;
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
window.oikos?.showToast('Titel ist erforderlich', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (name) ingredients.push({ name, quantity: qty, id: row.dataset.ingId || null });
|
||||||
|
});
|
||||||
|
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = '…';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { mode, meal } = state.modal;
|
||||||
|
|
||||||
|
if (mode === 'create') {
|
||||||
|
const res = await api.post('/meals', { date, meal_type, title, notes, ingredients });
|
||||||
|
state.meals.push(res.data);
|
||||||
|
} else {
|
||||||
|
// Update meal meta
|
||||||
|
await api.put(`/meals/${meal.id}`, { date, meal_type, title, notes });
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload updated meal
|
||||||
|
await loadWeek(state.currentWeek);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal();
|
||||||
|
renderWeekGrid();
|
||||||
|
window.oikos?.showToast(mode === 'create' ? 'Mahlzeit hinzugefügt' : 'Mahlzeit gespeichert', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
window.oikos?.showToast(err.data?.error ?? 'Fehler beim Speichern', 'error');
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = state.modal?.mode === 'edit' ? 'Speichern' : 'Hinzufügen';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Mahlzeit löschen
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
async function deleteMeal(mealId) {
|
||||||
|
if (!confirm('Mahlzeit wirklich löschen?')) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/meals/${mealId}`);
|
||||||
|
state.meals = state.meals.filter((m) => m.id !== mealId);
|
||||||
|
renderWeekGrid();
|
||||||
|
window.oikos?.showToast('Mahlzeit gelöscht', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
window.oikos?.showToast(err.data?.error ?? 'Fehler beim Löschen', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Zutaten → Einkaufsliste (Quick-Transfer vom Slot aus)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
async function transferMeal(mealId) {
|
||||||
|
if (!state.lists.length) {
|
||||||
|
window.oikos?.showToast('Keine Einkaufslisten vorhanden', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let listId = state.lists[0].id;
|
||||||
|
|
||||||
|
if (state.lists.length > 1) {
|
||||||
|
const names = state.lists.map((l, i) => `${i + 1}. ${l.name}`).join('\n');
|
||||||
|
const choice = prompt(`Auf welche Einkaufsliste?\n${names}\nNummer eingeben:`);
|
||||||
|
const n = parseInt(choice, 10);
|
||||||
|
if (!n || n < 1 || n > state.lists.length) return;
|
||||||
|
listId = state.lists[n - 1].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.post(`/meals/${mealId}/to-shopping-list`, { listId });
|
||||||
|
if (res.data.transferred > 0) {
|
||||||
|
window.oikos?.showToast(`${res.data.transferred} Zutat${res.data.transferred !== 1 ? 'en' : ''} übertragen`, 'success');
|
||||||
|
await loadWeek(state.currentWeek);
|
||||||
|
renderWeekGrid();
|
||||||
|
} else {
|
||||||
|
window.oikos?.showToast('Alle Zutaten bereits übertragen', 'info');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
window.oikos?.showToast(err.data?.error ?? 'Fehler beim Übertragen', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Hilfsfunktion
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
function escHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,509 @@
|
|||||||
|
/**
|
||||||
|
* Modul: Essensplan (Meals)
|
||||||
|
* Zweck: Styles für Wochenansicht, Mahlzeit-Karten, Zutaten-Liste, Modal
|
||||||
|
* Abhängigkeiten: tokens.css, layout.css
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Seiten-Layout
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.meals-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100dvh - var(--nav-height-mobile) - var(--safe-area-inset-bottom));
|
||||||
|
max-width: var(--content-max-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.meals-page {
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Wochen-Navigation
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.week-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-nav__label {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-nav__today {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-accent-light);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Wochengitter (scroll horizontal auf Mobil)
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.week-grid {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-column {
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-column:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-3) var(--space-4) var(--space-2);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
z-index: var(--z-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-header__name {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-header__date {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-header--today .day-header__name,
|
||||||
|
.day-header--today .day-header__date {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-slots {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: 0 var(--space-4) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.day-slots {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Mahlzeit-Slot
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.meal-slot {
|
||||||
|
min-height: 80px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1.5px dashed var(--color-border);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-slot--has-meal {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-slot__type-label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--color-text-disabled);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: var(--space-1) var(--space-2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slot-Typ-Farben */
|
||||||
|
.meal-slot[data-type="breakfast"] .meal-slot__type-label { color: #FF9500; }
|
||||||
|
.meal-slot[data-type="lunch"] .meal-slot__type-label { color: #34C759; }
|
||||||
|
.meal-slot[data-type="dinner"] .meal-slot__type-label { color: #007AFF; }
|
||||||
|
.meal-slot[data-type="snack"] .meal-slot__type-label { color: #FF6B35; }
|
||||||
|
|
||||||
|
.meal-slot__add-btn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-disabled);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
padding: var(--space-2);
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
min-height: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-slot__add-btn:hover {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Mahlzeit-Karte (innerhalb des Slots)
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.meal-card {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: var(--space-1) var(--space-2) var(--space-2);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-card__title {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-card__meta {
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-card__ingredients-count {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-card__ing-done {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-card__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-slot:hover .meal-card__actions,
|
||||||
|
.meal-slot:focus-within .meal-card__actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-card__action-btn {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-disabled);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: unset;
|
||||||
|
transition: color var(--transition-fast), background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-card__action-btn:hover {
|
||||||
|
color: var(--color-danger);
|
||||||
|
background-color: var(--color-surface-2, rgba(0,0,0,0.04));
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-card__action-btn--shopping:hover {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Modal — Mahlzeit erstellen / bearbeiten
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.meal-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: var(--z-modal);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
animation: fadeIn var(--transition-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.meal-modal-overlay {
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-modal {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 90dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: slideUp var(--transition-base) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.meal-modal {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: 80dvh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { transform: translateY(100%); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { transform: translateY(20px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-modal__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-modal__title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-modal__close {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--color-border);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
min-height: unset;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-modal__body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Formular-Zeile: Datum + Typ */
|
||||||
|
.meal-modal__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Autocomplete im Modal */
|
||||||
|
.meal-modal__autocomplete {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1.5px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
z-index: calc(var(--z-modal) + 1);
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-modal__autocomplete-item {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-modal__autocomplete-item:hover,
|
||||||
|
.meal-modal__autocomplete-item--active {
|
||||||
|
background-color: var(--color-accent-light);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zutaten-Bereich */
|
||||||
|
.ingredient-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingredient-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingredient-row__name {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingredient-row__qty {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingredient-row__remove {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-disabled);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: unset;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingredient-row__remove:hover {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-ingredient-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-accent);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--space-1) 0;
|
||||||
|
min-height: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Einkaufsliste-Auswahl im Modal */
|
||||||
|
.shopping-transfer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3);
|
||||||
|
background-color: var(--color-accent-light);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-transfer__label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-accent);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-transfer__select {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--color-accent);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-transfer__btn {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal-Footer */
|
||||||
|
.meal-modal__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Mahlzeit-Typ Labels (DE)
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.meal-type-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-type-badge--breakfast { background: #FFF3E0; color: #FF9500; }
|
||||||
|
.meal-type-badge--lunch { background: #E8F5E9; color: #2E7D32; }
|
||||||
|
.meal-type-badge--dinner { background: #E3F2FF; color: #007AFF; }
|
||||||
|
.meal-type-badge--snack { background: #FBE9E7; color: #FF6B35; }
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Leer-Zustand
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.meals-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-12) var(--space-6);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meals-empty__icon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
color: var(--color-text-disabled);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
+462
-5
@@ -1,13 +1,470 @@
|
|||||||
/**
|
/**
|
||||||
* Modul: Essensplan (Meals)
|
* Modul: Essensplan (Meals)
|
||||||
* Zweck: REST-API-Routen für Mahlzeiten und Zutaten
|
* Zweck: REST-API-Routen für Mahlzeiten, Zutaten und Einkaufslisten-Integration
|
||||||
* Abhängigkeiten: express, server/db.js, server/auth.js
|
* Abhängigkeiten: express, server/db.js, server/auth.js
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const express = require('express');
|
'use strict';
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// Platzhalter — wird in Phase 2 implementiert
|
const express = require('express');
|
||||||
router.get('/', (req, res) => res.json({ data: [] }));
|
const router = express.Router();
|
||||||
|
const db = require('../db');
|
||||||
|
|
||||||
|
const VALID_MEAL_TYPES = ['breakfast', 'lunch', 'dinner', 'snack'];
|
||||||
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Hilfsfunktionen
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den ISO-Datumstring (YYYY-MM-DD) für den Montag einer Woche zurück.
|
||||||
|
* @param {string} dateStr - beliebiges Datum der Woche (YYYY-MM-DD)
|
||||||
|
*/
|
||||||
|
function weekStart(dateStr) {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00Z');
|
||||||
|
const day = d.getUTCDay(); // 0 = So, 1 = Mo, …
|
||||||
|
const diff = (day === 0 ? -6 : 1 - day);
|
||||||
|
d.setUTCDate(d.getUTCDate() + diff);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den ISO-Datumstring für den Sonntag einer Woche zurück.
|
||||||
|
*/
|
||||||
|
function weekEnd(dateStr) {
|
||||||
|
const start = weekStart(dateStr);
|
||||||
|
const d = new Date(start + 'T00:00:00Z');
|
||||||
|
d.setUTCDate(d.getUTCDate() + 6);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Routen — Mahlzeiten-Vorschläge (vor dynamischen Routen!)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/meals/suggestions
|
||||||
|
* Autocomplete für Mahlzeit-Titel aus der Historie.
|
||||||
|
* Query: ?q=<string>
|
||||||
|
* Response: { data: [{ title, meal_type }] }
|
||||||
|
*/
|
||||||
|
router.get('/suggestions', (req, res) => {
|
||||||
|
try {
|
||||||
|
const q = (req.query.q || '').trim();
|
||||||
|
if (!q) return res.json({ data: [] });
|
||||||
|
|
||||||
|
const rows = db.get().prepare(`
|
||||||
|
SELECT DISTINCT title, meal_type
|
||||||
|
FROM meals
|
||||||
|
WHERE title LIKE ? COLLATE NOCASE
|
||||||
|
ORDER BY title ASC
|
||||||
|
LIMIT 10
|
||||||
|
`).all(`${q}%`);
|
||||||
|
|
||||||
|
res.json({ data: rows });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[meals/suggestions]', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Routen — Wochenübersicht
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/meals
|
||||||
|
* Alle Mahlzeiten einer Woche inkl. Zutaten.
|
||||||
|
* Query: ?week=YYYY-MM-DD (beliebiges Datum der gewünschten Woche; default: aktuelle Woche)
|
||||||
|
* Response: { data: Meal[], weekStart: string, weekEnd: string }
|
||||||
|
*
|
||||||
|
* Meal: { id, date, meal_type, title, notes, created_by, ingredients: Ingredient[] }
|
||||||
|
* Ingredient: { id, meal_id, name, quantity, on_shopping_list }
|
||||||
|
*/
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const refDate = req.query.week && DATE_RE.test(req.query.week)
|
||||||
|
? req.query.week
|
||||||
|
: new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const from = weekStart(refDate);
|
||||||
|
const to = weekEnd(refDate);
|
||||||
|
|
||||||
|
const meals = db.get().prepare(`
|
||||||
|
SELECT m.*, u.display_name AS creator_name, u.avatar_color AS creator_color
|
||||||
|
FROM meals m
|
||||||
|
LEFT JOIN users u ON u.id = m.created_by
|
||||||
|
WHERE m.date BETWEEN ? AND ?
|
||||||
|
ORDER BY m.date ASC,
|
||||||
|
CASE m.meal_type
|
||||||
|
WHEN 'breakfast' THEN 0
|
||||||
|
WHEN 'lunch' THEN 1
|
||||||
|
WHEN 'dinner' THEN 2
|
||||||
|
WHEN 'snack' THEN 3
|
||||||
|
ELSE 4
|
||||||
|
END ASC
|
||||||
|
`).all(from, to);
|
||||||
|
|
||||||
|
// Zutaten für alle Mahlzeiten in einer Abfrage holen
|
||||||
|
const mealIds = meals.map((m) => m.id);
|
||||||
|
let ingredientMap = {};
|
||||||
|
|
||||||
|
if (mealIds.length > 0) {
|
||||||
|
const placeholders = mealIds.map(() => '?').join(',');
|
||||||
|
const ingredients = db.get().prepare(`
|
||||||
|
SELECT * FROM meal_ingredients
|
||||||
|
WHERE meal_id IN (${placeholders})
|
||||||
|
ORDER BY id ASC
|
||||||
|
`).all(...mealIds);
|
||||||
|
|
||||||
|
for (const ing of ingredients) {
|
||||||
|
if (!ingredientMap[ing.meal_id]) ingredientMap[ing.meal_id] = [];
|
||||||
|
ingredientMap[ing.meal_id].push(ing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = meals.map((m) => ({
|
||||||
|
...m,
|
||||||
|
ingredients: ingredientMap[m.id] || [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ data: result, weekStart: from, weekEnd: to });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[meals/GET /]', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// CRUD — Mahlzeiten
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/meals
|
||||||
|
* Neue Mahlzeit anlegen.
|
||||||
|
* Body: { date, meal_type, title, notes?, ingredients?: [{ name, quantity? }] }
|
||||||
|
* Response: { data: Meal }
|
||||||
|
*/
|
||||||
|
router.post('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { date, meal_type, title, notes = null, ingredients = [] } = req.body;
|
||||||
|
|
||||||
|
if (!date || !DATE_RE.test(date))
|
||||||
|
return res.status(400).json({ error: 'Gültiges Datum (YYYY-MM-DD) erforderlich', code: 400 });
|
||||||
|
if (!meal_type || !VALID_MEAL_TYPES.includes(meal_type))
|
||||||
|
return res.status(400).json({ error: `meal_type muss einer von: ${VALID_MEAL_TYPES.join(', ')} sein`, code: 400 });
|
||||||
|
if (!title || !title.trim())
|
||||||
|
return res.status(400).json({ error: 'Titel ist erforderlich', code: 400 });
|
||||||
|
|
||||||
|
const meal = db.transaction(() => {
|
||||||
|
const result = db.get().prepare(`
|
||||||
|
INSERT INTO meals (date, meal_type, title, notes, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`).run(date, meal_type, title.trim(), notes || null, req.session.userId);
|
||||||
|
|
||||||
|
const mealId = result.lastInsertRowid;
|
||||||
|
|
||||||
|
const insertIng = db.get().prepare(`
|
||||||
|
INSERT INTO meal_ingredients (meal_id, name, quantity) VALUES (?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const ing of ingredients) {
|
||||||
|
if (ing.name && ing.name.trim()) {
|
||||||
|
insertIng.run(mealId, ing.name.trim(), ing.quantity?.trim() || null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.get().prepare(`
|
||||||
|
SELECT m.*, u.display_name AS creator_name, u.avatar_color AS creator_color
|
||||||
|
FROM meals m
|
||||||
|
LEFT JOIN users u ON u.id = m.created_by
|
||||||
|
WHERE m.id = ?
|
||||||
|
`).get(mealId);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Zutaten anhängen
|
||||||
|
const ings = db.get().prepare(
|
||||||
|
'SELECT * FROM meal_ingredients WHERE meal_id = ? ORDER BY id ASC'
|
||||||
|
).all(meal.id);
|
||||||
|
|
||||||
|
res.status(201).json({ data: { ...meal, ingredients: ings } });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[meals/POST /]', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/v1/meals/:id
|
||||||
|
* Mahlzeit bearbeiten (Titel, Notizen, Datum, Typ).
|
||||||
|
* Body: { date?, meal_type?, title?, notes? }
|
||||||
|
* Response: { data: Meal }
|
||||||
|
*/
|
||||||
|
router.put('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const meal = db.get().prepare('SELECT * FROM meals WHERE id = ?').get(id);
|
||||||
|
if (!meal) return res.status(404).json({ error: 'Mahlzeit nicht gefunden', code: 404 });
|
||||||
|
|
||||||
|
const { date, meal_type, title, notes } = req.body;
|
||||||
|
|
||||||
|
if (date !== undefined && !DATE_RE.test(date))
|
||||||
|
return res.status(400).json({ error: 'Ungültiges Datum', code: 400 });
|
||||||
|
if (meal_type !== undefined && !VALID_MEAL_TYPES.includes(meal_type))
|
||||||
|
return res.status(400).json({ error: 'Ungültiger meal_type', code: 400 });
|
||||||
|
|
||||||
|
db.get().prepare(`
|
||||||
|
UPDATE meals
|
||||||
|
SET date = COALESCE(?, date),
|
||||||
|
meal_type = COALESCE(?, meal_type),
|
||||||
|
title = COALESCE(?, title),
|
||||||
|
notes = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(
|
||||||
|
date ?? null,
|
||||||
|
meal_type ?? null,
|
||||||
|
title?.trim() ?? null,
|
||||||
|
notes !== undefined ? (notes || null) : meal.notes,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = db.get().prepare(`
|
||||||
|
SELECT m.*, u.display_name AS creator_name, u.avatar_color AS creator_color
|
||||||
|
FROM meals m LEFT JOIN users u ON u.id = m.created_by
|
||||||
|
WHERE m.id = ?
|
||||||
|
`).get(id);
|
||||||
|
|
||||||
|
const ings = db.get().prepare(
|
||||||
|
'SELECT * FROM meal_ingredients WHERE meal_id = ? ORDER BY id ASC'
|
||||||
|
).all(id);
|
||||||
|
|
||||||
|
res.json({ data: { ...updated, ingredients: ings } });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[meals/PUT /:id]', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/v1/meals/:id
|
||||||
|
* Mahlzeit löschen (Zutaten werden per CASCADE mitgelöscht).
|
||||||
|
* Response: 204 No Content
|
||||||
|
*/
|
||||||
|
router.delete('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const result = db.get().prepare('DELETE FROM meals WHERE id = ?').run(id);
|
||||||
|
if (result.changes === 0)
|
||||||
|
return res.status(404).json({ error: 'Mahlzeit nicht gefunden', code: 404 });
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[meals/DELETE /:id]', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// CRUD — Zutaten
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/meals/:id/ingredients
|
||||||
|
* Zutat zur Mahlzeit hinzufügen.
|
||||||
|
* Body: { name, quantity? }
|
||||||
|
* Response: { data: Ingredient }
|
||||||
|
*/
|
||||||
|
router.post('/:id/ingredients', (req, res) => {
|
||||||
|
try {
|
||||||
|
const mealId = parseInt(req.params.id, 10);
|
||||||
|
const meal = db.get().prepare('SELECT id FROM meals WHERE id = ?').get(mealId);
|
||||||
|
if (!meal) return res.status(404).json({ error: 'Mahlzeit nicht gefunden', code: 404 });
|
||||||
|
|
||||||
|
const { name, quantity = null } = req.body;
|
||||||
|
if (!name || !name.trim())
|
||||||
|
return res.status(400).json({ error: 'Name ist erforderlich', code: 400 });
|
||||||
|
|
||||||
|
const result = db.get().prepare(`
|
||||||
|
INSERT INTO meal_ingredients (meal_id, name, quantity) VALUES (?, ?, ?)
|
||||||
|
`).run(mealId, name.trim(), quantity?.trim() || null);
|
||||||
|
|
||||||
|
const ing = db.get().prepare(
|
||||||
|
'SELECT * FROM meal_ingredients WHERE id = ?'
|
||||||
|
).get(result.lastInsertRowid);
|
||||||
|
|
||||||
|
res.status(201).json({ data: ing });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[meals/POST /:id/ingredients]', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/v1/meals/ingredients/:ingId
|
||||||
|
* Zutat bearbeiten (Name, Menge, on_shopping_list-Flag).
|
||||||
|
* Body: { name?, quantity?, on_shopping_list? }
|
||||||
|
* Response: { data: Ingredient }
|
||||||
|
*/
|
||||||
|
router.patch('/ingredients/:ingId', (req, res) => {
|
||||||
|
try {
|
||||||
|
const ingId = parseInt(req.params.ingId, 10);
|
||||||
|
const ing = db.get().prepare('SELECT * FROM meal_ingredients WHERE id = ?').get(ingId);
|
||||||
|
if (!ing) return res.status(404).json({ error: 'Zutat nicht gefunden', code: 404 });
|
||||||
|
|
||||||
|
const { name, quantity, on_shopping_list } = req.body;
|
||||||
|
|
||||||
|
db.get().prepare(`
|
||||||
|
UPDATE meal_ingredients
|
||||||
|
SET name = COALESCE(?, name),
|
||||||
|
quantity = ?,
|
||||||
|
on_shopping_list = COALESCE(?, on_shopping_list)
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(
|
||||||
|
name?.trim() ?? null,
|
||||||
|
quantity !== undefined ? (quantity?.trim() || null) : ing.quantity,
|
||||||
|
on_shopping_list !== undefined ? (on_shopping_list ? 1 : 0) : null,
|
||||||
|
ingId
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = db.get().prepare(
|
||||||
|
'SELECT * FROM meal_ingredients WHERE id = ?'
|
||||||
|
).get(ingId);
|
||||||
|
|
||||||
|
res.json({ data: updated });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[meals/PATCH /ingredients/:ingId]', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/v1/meals/ingredients/:ingId
|
||||||
|
* Zutat löschen.
|
||||||
|
* Response: 204 No Content
|
||||||
|
*/
|
||||||
|
router.delete('/ingredients/:ingId', (req, res) => {
|
||||||
|
try {
|
||||||
|
const ingId = parseInt(req.params.ingId, 10);
|
||||||
|
const result = db.get().prepare('DELETE FROM meal_ingredients WHERE id = ?').run(ingId);
|
||||||
|
if (result.changes === 0)
|
||||||
|
return res.status(404).json({ error: 'Zutat nicht gefunden', code: 404 });
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[meals/DELETE /ingredients/:ingId]', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Integration: Zutaten → Einkaufsliste (Phase 2, Schritt 12)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/meals/:id/to-shopping-list
|
||||||
|
* Alle noch nicht übertragenen Zutaten einer Mahlzeit auf eine Einkaufsliste übernehmen.
|
||||||
|
* Body: { listId: number, category?: string }
|
||||||
|
* Response: { data: { transferred: number } }
|
||||||
|
*/
|
||||||
|
router.post('/:id/to-shopping-list', (req, res) => {
|
||||||
|
try {
|
||||||
|
const mealId = parseInt(req.params.id, 10);
|
||||||
|
const meal = db.get().prepare('SELECT id FROM meals WHERE id = ?').get(mealId);
|
||||||
|
if (!meal) return res.status(404).json({ error: 'Mahlzeit nicht gefunden', code: 404 });
|
||||||
|
|
||||||
|
const { listId, category = 'Sonstiges' } = req.body;
|
||||||
|
if (!listId)
|
||||||
|
return res.status(400).json({ error: 'listId ist erforderlich', code: 400 });
|
||||||
|
|
||||||
|
const list = db.get().prepare('SELECT id FROM shopping_lists WHERE id = ?').get(listId);
|
||||||
|
if (!list) return res.status(404).json({ error: 'Einkaufsliste nicht gefunden', code: 404 });
|
||||||
|
|
||||||
|
const ingredients = db.get().prepare(`
|
||||||
|
SELECT * FROM meal_ingredients
|
||||||
|
WHERE meal_id = ? AND on_shopping_list = 0
|
||||||
|
`).all(mealId);
|
||||||
|
|
||||||
|
if (ingredients.length === 0)
|
||||||
|
return res.json({ data: { transferred: 0 } });
|
||||||
|
|
||||||
|
const transferred = db.transaction(() => {
|
||||||
|
const insertItem = db.get().prepare(`
|
||||||
|
INSERT INTO shopping_items (list_id, name, quantity, category, added_from_meal)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
const markDone = db.get().prepare(`
|
||||||
|
UPDATE meal_ingredients SET on_shopping_list = 1 WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
for (const ing of ingredients) {
|
||||||
|
insertItem.run(listId, ing.name, ing.quantity, category, mealId);
|
||||||
|
markDone.run(ing.id);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
})();
|
||||||
|
|
||||||
|
res.json({ data: { transferred } });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[meals/POST /:id/to-shopping-list]', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/meals/week-to-shopping-list
|
||||||
|
* Alle noch nicht übertragenen Zutaten einer ganzen Woche auf eine Einkaufsliste übernehmen.
|
||||||
|
* Body: { listId, week: YYYY-MM-DD, category? }
|
||||||
|
* Response: { data: { transferred: number } }
|
||||||
|
*/
|
||||||
|
router.post('/week-to-shopping-list', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { listId, week, category = 'Sonstiges' } = req.body;
|
||||||
|
|
||||||
|
if (!listId)
|
||||||
|
return res.status(400).json({ error: 'listId ist erforderlich', code: 400 });
|
||||||
|
if (!week || !DATE_RE.test(week))
|
||||||
|
return res.status(400).json({ error: 'Gültiges Datum (YYYY-MM-DD) erforderlich', code: 400 });
|
||||||
|
|
||||||
|
const list = db.get().prepare('SELECT id FROM shopping_lists WHERE id = ?').get(listId);
|
||||||
|
if (!list) return res.status(404).json({ error: 'Einkaufsliste nicht gefunden', code: 404 });
|
||||||
|
|
||||||
|
const from = weekStart(week);
|
||||||
|
const to = weekEnd(week);
|
||||||
|
|
||||||
|
const ingredients = db.get().prepare(`
|
||||||
|
SELECT mi.* FROM meal_ingredients mi
|
||||||
|
JOIN meals m ON m.id = mi.meal_id
|
||||||
|
WHERE m.date BETWEEN ? AND ?
|
||||||
|
AND mi.on_shopping_list = 0
|
||||||
|
`).all(from, to);
|
||||||
|
|
||||||
|
if (ingredients.length === 0)
|
||||||
|
return res.json({ data: { transferred: 0 } });
|
||||||
|
|
||||||
|
const transferred = db.transaction(() => {
|
||||||
|
const insertItem = db.get().prepare(`
|
||||||
|
INSERT INTO shopping_items (list_id, name, quantity, category, added_from_meal)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
const markDone = db.get().prepare(`
|
||||||
|
UPDATE meal_ingredients SET on_shopping_list = 1 WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
for (const ing of ingredients) {
|
||||||
|
insertItem.run(listId, ing.name, ing.quantity, category, ing.meal_id);
|
||||||
|
markDone.run(ing.id);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
})();
|
||||||
|
|
||||||
|
res.json({ data: { transferred } });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[meals/POST /week-to-shopping-list]', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
+317
@@ -0,0 +1,317 @@
|
|||||||
|
/**
|
||||||
|
* Modul: Essensplan-Test
|
||||||
|
* Zweck: Validiert alle Meals-API-Abfragen, Zutaten-CRUD, Wochensortierung,
|
||||||
|
* Einkaufslisten-Integration
|
||||||
|
* Ausführen: node --experimental-sqlite test-meals.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { DatabaseSync } = require('node:sqlite');
|
||||||
|
const { MIGRATIONS_SQL } = require('./server/db-schema-test');
|
||||||
|
|
||||||
|
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'); }
|
||||||
|
|
||||||
|
const db = new DatabaseSync(':memory:');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON;');
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY, description TEXT NOT NULL,
|
||||||
|
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
);`);
|
||||||
|
db.exec(MIGRATIONS_SQL[1]);
|
||||||
|
|
||||||
|
// Test-Benutzer
|
||||||
|
const u1 = db.prepare(`INSERT INTO users (username, display_name, password_hash, role)
|
||||||
|
VALUES ('admin', 'Admin', 'x', 'admin')`).run();
|
||||||
|
const uid = u1.lastInsertRowid;
|
||||||
|
|
||||||
|
// Einkaufsliste für Integration-Tests
|
||||||
|
const sl = db.prepare(`INSERT INTO shopping_lists (name, created_by) VALUES ('REWE', ?)`).run(uid);
|
||||||
|
const listId = sl.lastInsertRowid;
|
||||||
|
|
||||||
|
console.log('\n[Meals-Test] Wochenplan, Zutaten, Einkaufslisten-Integration\n');
|
||||||
|
|
||||||
|
let mealId1, mealId2, mealId3, ingId1, ingId2;
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Mahlzeit CRUD
|
||||||
|
// --------------------------------------------------------
|
||||||
|
test('Mahlzeit erstellen (Mittagessen)', () => {
|
||||||
|
const r = db.prepare(`
|
||||||
|
INSERT INTO meals (date, meal_type, title, notes, created_by)
|
||||||
|
VALUES ('2026-03-23', 'lunch', 'Spaghetti Bolognese', 'Klassiker', ?)
|
||||||
|
`).run(uid);
|
||||||
|
mealId1 = r.lastInsertRowid;
|
||||||
|
assert(mealId1 > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Mahlzeit erstellen (Frühstück)', () => {
|
||||||
|
const r = db.prepare(`
|
||||||
|
INSERT INTO meals (date, meal_type, title, created_by)
|
||||||
|
VALUES ('2026-03-23', 'breakfast', 'Müsli mit Früchten', ?)
|
||||||
|
`).run(uid);
|
||||||
|
mealId2 = r.lastInsertRowid;
|
||||||
|
assert(mealId2 > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Mahlzeit erstellen (andere Woche)', () => {
|
||||||
|
const r = db.prepare(`
|
||||||
|
INSERT INTO meals (date, meal_type, title, created_by)
|
||||||
|
VALUES ('2026-03-30', 'dinner', 'Pizza Margherita', ?)
|
||||||
|
`).run(uid);
|
||||||
|
mealId3 = r.lastInsertRowid;
|
||||||
|
assert(mealId3 > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Mahlzeiten einer Woche abrufen', () => {
|
||||||
|
const meals = db.prepare(`
|
||||||
|
SELECT * FROM meals
|
||||||
|
WHERE date BETWEEN '2026-03-23' AND '2026-03-29'
|
||||||
|
ORDER BY date ASC,
|
||||||
|
CASE meal_type
|
||||||
|
WHEN 'breakfast' THEN 0
|
||||||
|
WHEN 'lunch' THEN 1
|
||||||
|
WHEN 'dinner' THEN 2
|
||||||
|
WHEN 'snack' THEN 3
|
||||||
|
ELSE 4
|
||||||
|
END ASC
|
||||||
|
`).all();
|
||||||
|
assert(meals.length === 2, `Erwartet 2, erhalten ${meals.length}`);
|
||||||
|
assert(meals[0].meal_type === 'breakfast', 'Frühstück zuerst');
|
||||||
|
assert(meals[1].meal_type === 'lunch', 'Mittagessen danach');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Andere Woche hat nur eigene Mahlzeiten', () => {
|
||||||
|
const meals = db.prepare(`
|
||||||
|
SELECT * FROM meals WHERE date BETWEEN '2026-03-30' AND '2026-04-05'
|
||||||
|
`).all();
|
||||||
|
assert(meals.length === 1, `Erwartet 1, erhalten ${meals.length}`);
|
||||||
|
assert(meals[0].title === 'Pizza Margherita');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Mahlzeit aktualisieren', () => {
|
||||||
|
db.prepare(`UPDATE meals SET title = 'Spaghetti Carbonara', notes = NULL WHERE id = ?`).run(mealId1);
|
||||||
|
const m = db.prepare('SELECT title, notes FROM meals WHERE id = ?').get(mealId1);
|
||||||
|
assert(m.title === 'Spaghetti Carbonara', 'Titel aktualisiert');
|
||||||
|
assert(m.notes === null, 'Notizen gelöscht');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Mahlzeit-Typ-Constraint (ungültiger Wert)', () => {
|
||||||
|
let threw = false;
|
||||||
|
try {
|
||||||
|
db.prepare(`INSERT INTO meals (date, meal_type, title, created_by) VALUES ('2026-03-24', 'brunch', 'Test', ?)`).run(uid);
|
||||||
|
} catch { threw = true; }
|
||||||
|
assert(threw, 'Constraint muss verletzt werden');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Zutaten CRUD
|
||||||
|
// --------------------------------------------------------
|
||||||
|
test('Zutat hinzufügen', () => {
|
||||||
|
const r = db.prepare(`
|
||||||
|
INSERT INTO meal_ingredients (meal_id, name, quantity)
|
||||||
|
VALUES (?, 'Hackfleisch', '500g')
|
||||||
|
`).run(mealId1);
|
||||||
|
ingId1 = r.lastInsertRowid;
|
||||||
|
assert(ingId1 > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Zweite Zutat hinzufügen', () => {
|
||||||
|
const r = db.prepare(`
|
||||||
|
INSERT INTO meal_ingredients (meal_id, name, quantity)
|
||||||
|
VALUES (?, 'Spaghetti', '400g')
|
||||||
|
`).run(mealId1);
|
||||||
|
ingId2 = r.lastInsertRowid;
|
||||||
|
assert(ingId2 > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Zutaten einer Mahlzeit abrufen', () => {
|
||||||
|
const ings = db.prepare(`
|
||||||
|
SELECT * FROM meal_ingredients WHERE meal_id = ? ORDER BY id ASC
|
||||||
|
`).all(mealId1);
|
||||||
|
assert(ings.length === 2, `Erwartet 2, erhalten ${ings.length}`);
|
||||||
|
assert(ings[0].name === 'Hackfleisch');
|
||||||
|
assert(ings[1].name === 'Spaghetti');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Zutat aktualisieren (Menge ändern)', () => {
|
||||||
|
db.prepare(`UPDATE meal_ingredients SET quantity = '600g' WHERE id = ?`).run(ingId1);
|
||||||
|
const ing = db.prepare('SELECT quantity FROM meal_ingredients WHERE id = ?').get(ingId1);
|
||||||
|
assert(ing.quantity === '600g', 'Menge aktualisiert');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('on_shopping_list-Flag setzen', () => {
|
||||||
|
db.prepare(`UPDATE meal_ingredients SET on_shopping_list = 1 WHERE id = ?`).run(ingId1);
|
||||||
|
const ing = db.prepare('SELECT on_shopping_list FROM meal_ingredients WHERE id = ?').get(ingId1);
|
||||||
|
assert(ing.on_shopping_list === 1, 'Flag gesetzt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Nur offene Zutaten haben on_shopping_list = 0', () => {
|
||||||
|
const open = db.prepare(`
|
||||||
|
SELECT * FROM meal_ingredients WHERE meal_id = ? AND on_shopping_list = 0
|
||||||
|
`).all(mealId1);
|
||||||
|
assert(open.length === 1, `Erwartet 1 offene Zutat, erhalten ${open.length}`);
|
||||||
|
assert(open[0].name === 'Spaghetti');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Zutat löschen', () => {
|
||||||
|
db.prepare('DELETE FROM meal_ingredients WHERE id = ?').run(ingId2);
|
||||||
|
const remaining = db.prepare('SELECT * FROM meal_ingredients WHERE meal_id = ?').all(mealId1);
|
||||||
|
assert(remaining.length === 1, 'Nur noch eine Zutat');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Cascade-Verhalten
|
||||||
|
// --------------------------------------------------------
|
||||||
|
test('Mahlzeit löschen entfernt Zutaten (CASCADE)', () => {
|
||||||
|
// Neue Mahlzeit mit Zutat
|
||||||
|
const m = db.prepare(`
|
||||||
|
INSERT INTO meals (date, meal_type, title, created_by)
|
||||||
|
VALUES ('2026-03-25', 'snack', 'Apfel', ?)
|
||||||
|
`).run(uid);
|
||||||
|
db.prepare(`INSERT INTO meal_ingredients (meal_id, name) VALUES (?, 'Apfel')`).run(m.lastInsertRowid);
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM meals WHERE id = ?').run(m.lastInsertRowid);
|
||||||
|
const ings = db.prepare('SELECT * FROM meal_ingredients WHERE meal_id = ?').all(m.lastInsertRowid);
|
||||||
|
assert(ings.length === 0, 'Zutaten nach Mahlzeit-Löschung entfernt');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Einkaufslisten-Integration
|
||||||
|
// --------------------------------------------------------
|
||||||
|
test('Offene Zutaten einer Woche abfragen', () => {
|
||||||
|
// Mahlzeit für Integration-Test anlegen
|
||||||
|
const m = db.prepare(`
|
||||||
|
INSERT INTO meals (date, meal_type, title, created_by)
|
||||||
|
VALUES ('2026-03-24', 'dinner', 'Risotto', ?)
|
||||||
|
`).run(uid);
|
||||||
|
const mid = m.lastInsertRowid;
|
||||||
|
|
||||||
|
db.prepare(`INSERT INTO meal_ingredients (meal_id, name, quantity) VALUES (?, 'Reis', '300g')`).run(mid);
|
||||||
|
db.prepare(`INSERT INTO meal_ingredients (meal_id, name, quantity) VALUES (?, 'Parmesan', '100g')`).run(mid);
|
||||||
|
|
||||||
|
const open = db.prepare(`
|
||||||
|
SELECT mi.* FROM meal_ingredients mi
|
||||||
|
JOIN meals mo ON mo.id = mi.meal_id
|
||||||
|
WHERE mo.date BETWEEN '2026-03-23' AND '2026-03-29'
|
||||||
|
AND mi.on_shopping_list = 0
|
||||||
|
`).all();
|
||||||
|
// Spaghetti (ingId1 wurde gelöscht), Hackfleisch (on_shopping_list=1 gesetzt), Reis, Parmesan
|
||||||
|
assert(open.length >= 2, `Mindestens 2 offene Zutaten, erhalten ${open.length}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Zutaten → Einkaufsliste übertragen (INSERT + Flag setzen)', () => {
|
||||||
|
// Frische Mahlzeit mit 2 Zutaten
|
||||||
|
const m = db.prepare(`
|
||||||
|
INSERT INTO meals (date, meal_type, title, created_by)
|
||||||
|
VALUES ('2026-03-24', 'lunch', 'Suppe', ?)
|
||||||
|
`).run(uid);
|
||||||
|
const mid = m.lastInsertRowid;
|
||||||
|
|
||||||
|
const i1 = db.prepare(`INSERT INTO meal_ingredients (meal_id, name, quantity) VALUES (?, 'Karotten', '3 Stück')`).run(mid).lastInsertRowid;
|
||||||
|
const i2 = db.prepare(`INSERT INTO meal_ingredients (meal_id, name) VALUES (?, 'Zwiebeln')`).run(mid).lastInsertRowid;
|
||||||
|
|
||||||
|
// Transfer-Logik aus server/routes/meals.js simulieren
|
||||||
|
const ingredients = db.prepare(`
|
||||||
|
SELECT * FROM meal_ingredients WHERE meal_id = ? AND on_shopping_list = 0
|
||||||
|
`).all(mid);
|
||||||
|
|
||||||
|
assert(ingredients.length === 2, `Erwartet 2, erhalten ${ingredients.length}`);
|
||||||
|
|
||||||
|
const insertItem = db.prepare(`
|
||||||
|
INSERT INTO shopping_items (list_id, name, quantity, category, added_from_meal)
|
||||||
|
VALUES (?, ?, ?, 'Sonstiges', ?)
|
||||||
|
`);
|
||||||
|
const markDone = db.prepare(`UPDATE meal_ingredients SET on_shopping_list = 1 WHERE id = ?`);
|
||||||
|
|
||||||
|
for (const ing of ingredients) {
|
||||||
|
insertItem.run(listId, ing.name, ing.quantity, mid);
|
||||||
|
markDone.run(ing.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfen: Artikel in Einkaufsliste
|
||||||
|
const items = db.prepare(`
|
||||||
|
SELECT * FROM shopping_items WHERE added_from_meal = ?
|
||||||
|
`).all(mid);
|
||||||
|
assert(items.length === 2, `Erwartet 2 Einkaufsartikel, erhalten ${items.length}`);
|
||||||
|
assert(items[0].name === 'Karotten', `Erster Artikel: ${items[0].name}`);
|
||||||
|
|
||||||
|
// Prüfen: Flags gesetzt
|
||||||
|
const stillOpen = db.prepare(`
|
||||||
|
SELECT * FROM meal_ingredients WHERE meal_id = ? AND on_shopping_list = 0
|
||||||
|
`).all(mid);
|
||||||
|
assert(stillOpen.length === 0, 'Alle Zutaten als übertragen markiert');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Zweiter Transfer überträgt nichts (alle bereits markiert)', () => {
|
||||||
|
// Mahlzeit aus vorherigem Test — alle on_shopping_list = 1
|
||||||
|
const suppe = db.prepare(`SELECT id FROM meals WHERE title = 'Suppe'`).get();
|
||||||
|
const open = db.prepare(`
|
||||||
|
SELECT * FROM meal_ingredients WHERE meal_id = ? AND on_shopping_list = 0
|
||||||
|
`).all(suppe.id);
|
||||||
|
assert(open.length === 0, 'Keine offenen Zutaten mehr');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('added_from_meal FK auf meals(id) gesetzt', () => {
|
||||||
|
const items = db.prepare(`
|
||||||
|
SELECT si.*, m.title AS meal_title
|
||||||
|
FROM shopping_items si
|
||||||
|
JOIN meals m ON m.id = si.added_from_meal
|
||||||
|
WHERE si.added_from_meal IS NOT NULL
|
||||||
|
LIMIT 5
|
||||||
|
`).all();
|
||||||
|
assert(items.length > 0, 'Mindestens ein Artikel mit Mahlzeit-Referenz');
|
||||||
|
assert(items[0].meal_title, 'meal_title verknüpft');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Autocomplete-Simulation
|
||||||
|
// --------------------------------------------------------
|
||||||
|
test('Mahlzeit-Autocomplete nach Prefix', () => {
|
||||||
|
const results = db.prepare(`
|
||||||
|
SELECT DISTINCT title, meal_type FROM meals
|
||||||
|
WHERE title LIKE ? COLLATE NOCASE
|
||||||
|
ORDER BY title ASC LIMIT 10
|
||||||
|
`).all('S%');
|
||||||
|
assert(results.length >= 1, `Mindestens 1 Treffer, erhalten ${results.length}`);
|
||||||
|
const titles = results.map((r) => r.title);
|
||||||
|
assert(titles.some((t) => t.startsWith('S') || t.startsWith('s')), 'Treffer beginnt mit S');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Autocomplete ohne Treffer gibt leeres Array', () => {
|
||||||
|
const results = db.prepare(`
|
||||||
|
SELECT DISTINCT title FROM meals WHERE title LIKE ? COLLATE NOCASE
|
||||||
|
`).all('XXXXXXXXXXX%');
|
||||||
|
assert(results.length === 0, 'Leeres Ergebnis erwartet');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Wochenhelfer-Logik (ohne Server)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
test('Wochenberechnung: Montag der aktuellen Woche', () => {
|
||||||
|
// 2026-03-24 ist ein Dienstag → Montag ist 2026-03-23
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
assert(getMondayOf('2026-03-24') === '2026-03-23', 'Montag korrekt berechnet');
|
||||||
|
assert(getMondayOf('2026-03-23') === '2026-03-23', 'Montag bleibt Montag');
|
||||||
|
assert(getMondayOf('2026-03-29') === '2026-03-23', 'Sonntag → gleicher Montag');
|
||||||
|
assert(getMondayOf('2026-03-30') === '2026-03-30', 'Nächster Montag');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Ergebnis
|
||||||
|
// --------------------------------------------------------
|
||||||
|
console.log(`\n[Meals-Test] Ergebnis: ${passed} bestanden, ${failed} fehlgeschlagen\n`);
|
||||||
|
if (failed > 0) process.exit(1);
|
||||||
Reference in New Issue
Block a user