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:
ulsklyc
2026-03-24 20:28:19 +01:00
parent 2ab250cc35
commit c344d59d5a
6 changed files with 1871 additions and 19 deletions
+1
View File
@@ -19,6 +19,7 @@
<link rel="stylesheet" href="/styles/dashboard.css" />
<link rel="stylesheet" href="/styles/tasks.css" />
<link rel="stylesheet" href="/styles/shopping.css" />
<link rel="stylesheet" href="/styles/meals.css" />
<!-- Lucide Icons (CDN) -->
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
+580 -13
View File
@@ -1,25 +1,592 @@
/**
* Modul: Meals
* Zweck: Seite für das Meals-Modul
* Abhängigkeiten: /api.js
* 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';
/**
* @param {HTMLElement} container
* @param {{ user: object }} context
*/
// --------------------------------------------------------
// Konstanten
// --------------------------------------------------------
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 }) {
container.innerHTML = `
<div class="page">
<div class="page__header">
<h1 class="page__title">Meals</h1>
<div class="meals-page">
<div class="week-nav">
<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 class="empty-state">
<div class="empty-state__title">Kommt bald.</div>
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
<div class="week-grid" id="week-grid">
<div style="margin:auto;padding:2rem;text-align:center;color:var(--color-text-disabled)">Lade…</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>
`;
}
// --------------------------------------------------------
// 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
+509
View File
@@ -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);
}