diff --git a/CHANGELOG.md b/CHANGELOG.md index 9307fd5..d527ab2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.19.6] - 2026-04-15 + +### Added +- Meals: ingredient category selection when adding ingredients to a meal - each ingredient can now be assigned a shopping category (e.g. Fruit & Vegetables, Dairy, Meat & Fish) directly in the meal editor. Categories are automatically applied when transferring ingredients to the shopping list, so items appear pre-sorted in their correct category groups (closes #33) + ## [0.19.5] - 2026-04-14 ### Fixed diff --git a/package.json b/package.json index 7122007..ec531a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.19.5", + "version": "0.19.6", "description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.", "main": "server/index.js", "type": "module", diff --git a/public/locales/ar.json b/public/locales/ar.json index 854ffa2..1b00c2e 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -226,6 +226,8 @@ "addIngredient": "إضافة مكون", "ingredientNamePlaceholder": "المكون", "ingredientQtyPlaceholder": "الكمية", + "ingredientCategoryLabel": "الفئة", + "ingredientCategoryDefault": "متنوعات", "removeIngredient": "إزالة المكون", "transferLabel": "نقل المكونات إلى قائمة التسوق", "transferNow": "نقل الآن", @@ -596,4 +598,4 @@ "unitMonth": "شهر", "unitMonths": "أشهر" } -} +} \ No newline at end of file diff --git a/public/locales/de.json b/public/locales/de.json index 63a7d0f..94470bd 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -226,6 +226,8 @@ "addIngredient": "Zutat hinzufügen", "ingredientNamePlaceholder": "Zutat", "ingredientQtyPlaceholder": "Menge", + "ingredientCategoryLabel": "Kategorie", + "ingredientCategoryDefault": "Sonstiges", "removeIngredient": "Zutat entfernen", "transferLabel": "Zutaten auf Einkaufsliste übertragen", "transferNow": "Jetzt übertragen", diff --git a/public/locales/el.json b/public/locales/el.json index 87b0eda..bd22908 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -226,6 +226,8 @@ "addIngredient": "Προσθήκη υλικού", "ingredientNamePlaceholder": "Υλικό", "ingredientQtyPlaceholder": "Ποσότητα", + "ingredientCategoryLabel": "Κατηγορία", + "ingredientCategoryDefault": "Διάφορα", "removeIngredient": "Αφαίρεση υλικού", "transferLabel": "Μεταφορά υλικών στη λίστα αγορών", "transferNow": "Μεταφορά τώρα", @@ -596,4 +598,4 @@ "unitMonth": "μήνα", "unitMonths": "μήνες" } -} +} \ No newline at end of file diff --git a/public/locales/en.json b/public/locales/en.json index 3df0b55..21e257f 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -226,6 +226,8 @@ "addIngredient": "Add ingredient", "ingredientNamePlaceholder": "Ingredient", "ingredientQtyPlaceholder": "Quantity", + "ingredientCategoryLabel": "Category", + "ingredientCategoryDefault": "Miscellaneous", "removeIngredient": "Remove ingredient", "transferLabel": "Transfer ingredients to shopping list", "transferNow": "Transfer now", diff --git a/public/locales/es.json b/public/locales/es.json index 6790aa7..b4dbe21 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -226,6 +226,8 @@ "addIngredient": "Añadir ingrediente", "ingredientNamePlaceholder": "Ingrediente", "ingredientQtyPlaceholder": "Cantidad", + "ingredientCategoryLabel": "Categoría", + "ingredientCategoryDefault": "Varios", "removeIngredient": "Eliminar ingrediente", "transferLabel": "Transferir ingredientes a la lista de compras", "transferNow": "Transferir ahora", @@ -596,4 +598,4 @@ "unitMonth": "mes", "unitMonths": "meses" } -} +} \ No newline at end of file diff --git a/public/locales/fr.json b/public/locales/fr.json index 35daea5..efaa4c2 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -226,6 +226,8 @@ "addIngredient": "Ajouter un ingrédient", "ingredientNamePlaceholder": "Ingrédient", "ingredientQtyPlaceholder": "Quantité", + "ingredientCategoryLabel": "Catégorie", + "ingredientCategoryDefault": "Divers", "removeIngredient": "Supprimer l'ingrédient", "transferLabel": "Transférer les ingrédients vers la liste de courses", "transferNow": "Transférer maintenant", @@ -596,4 +598,4 @@ "unitMonth": "mois", "unitMonths": "mois" } -} +} \ No newline at end of file diff --git a/public/locales/hi.json b/public/locales/hi.json index 860e6d3..309e5c9 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -226,6 +226,8 @@ "addIngredient": "सामग्री जोड़ें", "ingredientNamePlaceholder": "सामग्री", "ingredientQtyPlaceholder": "मात्रा", + "ingredientCategoryLabel": "श्रेणी", + "ingredientCategoryDefault": "विविध", "removeIngredient": "सामग्री हटाएं", "transferLabel": "सामग्री खरीदारी सूची में जोड़ें", "transferNow": "अभी जोड़ें", @@ -596,4 +598,4 @@ "unitMonth": "माह", "unitMonths": "माह" } -} +} \ No newline at end of file diff --git a/public/locales/it.json b/public/locales/it.json index 5c9bcad..d9aab09 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -226,6 +226,8 @@ "addIngredient": "Aggiungi ingrediente", "ingredientNamePlaceholder": "Ingrediente", "ingredientQtyPlaceholder": "Quantità", + "ingredientCategoryLabel": "Categoria", + "ingredientCategoryDefault": "Varie", "removeIngredient": "Rimuovi ingrediente", "transferLabel": "Trasferisci ingredienti alla lista della spesa", "transferNow": "Trasferisci ora", @@ -596,4 +598,4 @@ "unitMonth": "mese", "unitMonths": "mesi" } -} +} \ No newline at end of file diff --git a/public/locales/ja.json b/public/locales/ja.json index f18defb..2a5c5c1 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -226,6 +226,8 @@ "addIngredient": "材料を追加", "ingredientNamePlaceholder": "材料", "ingredientQtyPlaceholder": "量", + "ingredientCategoryLabel": "カテゴリ", + "ingredientCategoryDefault": "その他", "removeIngredient": "材料を削除", "transferLabel": "材料を買い物リストに追加", "transferNow": "今すぐ追加", @@ -596,4 +598,4 @@ "unitMonth": "ヶ月", "unitMonths": "ヶ月" } -} +} \ No newline at end of file diff --git a/public/locales/pt.json b/public/locales/pt.json index 449c5df..a0c5960 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -226,6 +226,8 @@ "addIngredient": "Adicionar ingrediente", "ingredientNamePlaceholder": "Ingrediente", "ingredientQtyPlaceholder": "Qtd", + "ingredientCategoryLabel": "Categoria", + "ingredientCategoryDefault": "Outros", "removeIngredient": "Remover ingrediente", "transferLabel": "Transferir ingredientes para lista de compras", "transferNow": "Transferir agora", @@ -596,4 +598,4 @@ "unitMonth": "mês", "unitMonths": "meses" } -} +} \ No newline at end of file diff --git a/public/locales/ru.json b/public/locales/ru.json index 1b6af89..2b4405f 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -226,6 +226,8 @@ "addIngredient": "Добавить ингредиент", "ingredientNamePlaceholder": "Ингредиент", "ingredientQtyPlaceholder": "Количество", + "ingredientCategoryLabel": "Категория", + "ingredientCategoryDefault": "Разное", "removeIngredient": "Удалить ингредиент", "transferLabel": "Перенести ингредиенты в список покупок", "transferNow": "Перенести сейчас", @@ -596,4 +598,4 @@ "unitMonth": "месяц", "unitMonths": "месяцев" } -} +} \ No newline at end of file diff --git a/public/locales/sv.json b/public/locales/sv.json index bf05c1b..4693c2c 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -226,6 +226,8 @@ "addIngredient": "Tillsätt ingrediens", "ingredientNamePlaceholder": "Ingrediens", "ingredientQtyPlaceholder": "Kvantitet", + "ingredientCategoryLabel": "Kategori", + "ingredientCategoryDefault": "Övrigt", "removeIngredient": "Ta bort ingrediensen", "transferLabel": "Överför ingredienserna till inköpslistan", "transferNow": "Överför nu", @@ -596,4 +598,4 @@ "unitMonth": "månad", "unitMonths": "månader" } -} +} \ No newline at end of file diff --git a/public/locales/tr.json b/public/locales/tr.json index 0d7b267..c9ce1ac 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -226,6 +226,8 @@ "addIngredient": "Malzeme ekle", "ingredientNamePlaceholder": "Malzeme", "ingredientQtyPlaceholder": "Miktar", + "ingredientCategoryLabel": "Kategori", + "ingredientCategoryDefault": "Çeşitli", "removeIngredient": "Malzemeyi kaldır", "transferLabel": "Malzemeleri alışveriş listesine aktar", "transferNow": "Şimdi aktar", @@ -596,4 +598,4 @@ "unitMonth": "ay", "unitMonths": "ay" } -} +} \ No newline at end of file diff --git a/public/locales/zh.json b/public/locales/zh.json index 06a3c9d..9a7b717 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -226,6 +226,8 @@ "addIngredient": "添加食材", "ingredientNamePlaceholder": "食材", "ingredientQtyPlaceholder": "数量", + "ingredientCategoryLabel": "分类", + "ingredientCategoryDefault": "其他", "removeIngredient": "移除食材", "transferLabel": "将食材添加到购物清单", "transferNow": "立即添加", @@ -596,4 +598,4 @@ "unitMonth": "个月", "unitMonths": "个月" } -} +} \ No newline at end of file diff --git a/public/pages/meals.js b/public/pages/meals.js index 07dcfb9..1e5a0bf 100644 --- a/public/pages/meals.js +++ b/public/pages/meals.js @@ -34,6 +34,7 @@ let state = { currentWeek: null, // YYYY-MM-DD (Montag) meals: [], lists: [], // Einkaufslisten für Transfer-Dropdown + categories: [], // Einkaufskategorien für Zutaten modal: null, visibleMealTypes: ['breakfast', 'lunch', 'dinner', 'snack'], }; @@ -98,6 +99,15 @@ async function loadLists() { } } +async function loadCategories() { + try { + const res = await api.get('/shopping/categories'); + state.categories = res.data; + } catch { + state.categories = []; + } +} + async function loadPreferences() { try { const res = await api.get('/preferences'); @@ -137,7 +147,7 @@ export async function render(container, { user }) { const today = new Date().toISOString().slice(0, 10); const monday = getMondayOf(today); - await Promise.all([loadWeek(monday), loadLists(), loadPreferences()]); + await Promise.all([loadWeek(monday), loadLists(), loadPreferences(), loadCategories()]); renderWeekGrid(); wireNav(); } @@ -554,7 +564,7 @@ function buildModalContent({ mode, date, mealType, meal }) { : ``; const ingRows = isEdit && meal.ingredients?.length - ? meal.ingredients.map((ing) => ingredientRowHTML(ing.name, ing.quantity ?? '', ing.id)).join('') + ? meal.ingredients.map((ing) => ingredientRowHTML(ing.name, ing.quantity ?? '', ing.id, ing.category ?? 'Sonstiges')).join('') : ''; const hasIngOpen = isEdit && meal.ingredients?.some((i) => !i.on_shopping_list); @@ -620,11 +630,16 @@ function buildModalContent({ mode, date, mealType, meal }) { `; } -function ingredientRowHTML(name, qty, id) { +function ingredientRowHTML(name, qty, id, category = 'Sonstiges') { + const catOptions = state.categories.length + ? state.categories.map((c) => ``).join('') + : ``; + return `
+ @@ -652,9 +667,10 @@ async function saveModal(overlay) { const ingredients = []; overlay.querySelectorAll('.ingredient-row').forEach((row) => { - const name = row.querySelector('.ingredient-row__name').value.trim(); - const qty = row.querySelector('.ingredient-row__qty').value.trim() || null; - if (name) ingredients.push({ name, quantity: qty, id: row.dataset.ingId || null }); + const name = row.querySelector('.ingredient-row__name').value.trim(); + const qty = row.querySelector('.ingredient-row__qty').value.trim() || null; + const category = row.querySelector('.ingredient-row__cat')?.value || 'Sonstiges'; + if (name) ingredients.push({ name, quantity: qty, category, id: row.dataset.ingId || null }); }); saveBtn.disabled = true; @@ -680,7 +696,7 @@ async function saveModal(overlay) { 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 }); + if (!ing.id) await api.post(`/meals/${meal.id}/ingredients`, { name: ing.name, quantity: ing.quantity, category: ing.category }); } // Reload updated meal diff --git a/public/styles/meals.css b/public/styles/meals.css index cc1bd80..8c714f5 100644 --- a/public/styles/meals.css +++ b/public/styles/meals.css @@ -329,6 +329,12 @@ min-width: 0; } +.ingredient-row__cat { + flex: 1.5; + min-width: 0; + font-size: var(--text-sm); +} + .ingredient-row__remove { width: 32px; height: 32px; diff --git a/server/db.js b/server/db.js index 92b6586..2060b18 100644 --- a/server/db.js +++ b/server/db.js @@ -362,6 +362,13 @@ const MIGRATIONS = [ ALTER TABLE meals ADD COLUMN recipe_url TEXT; `, }, + { + version: 7, + description: 'Kategorie pro Zutat für Einkaufslisten-Transfer', + up: ` + ALTER TABLE meal_ingredients ADD COLUMN category TEXT NOT NULL DEFAULT 'Sonstiges'; + `, + }, ]; /** diff --git a/server/routes/meals.js b/server/routes/meals.js index 612c628..144216b 100644 --- a/server/routes/meals.js +++ b/server/routes/meals.js @@ -169,13 +169,14 @@ router.post('/', (req, res) => { const mealId = result.lastInsertRowid; const insertIng = db.get().prepare(` - INSERT INTO meal_ingredients (meal_id, name, quantity) VALUES (?, ?, ?) + INSERT INTO meal_ingredients (meal_id, name, quantity, category) VALUES (?, ?, ?, ?) `); for (const ing of ingredients) { - const name = String(ing.name || '').trim().slice(0, MAX_TITLE); - const qty = String(ing.quantity || '').trim().slice(0, MAX_SHORT) || null; - if (name) insertIng.run(mealId, name, qty); + const name = String(ing.name || '').trim().slice(0, MAX_TITLE); + const qty = String(ing.quantity || '').trim().slice(0, MAX_SHORT) || null; + const category = String(ing.category || '').trim().slice(0, MAX_SHORT) || 'Sonstiges'; + if (name) insertIng.run(mealId, name, qty, category); } return db.get().prepare(` @@ -287,13 +288,13 @@ router.post('/:id/ingredients', (req, res) => { 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; + const { name, quantity = null, category = 'Sonstiges' } = 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); + INSERT INTO meal_ingredients (meal_id, name, quantity, category) VALUES (?, ?, ?, ?) + `).run(mealId, name.trim(), quantity?.trim() || null, String(category || '').trim() || 'Sonstiges'); const ing = db.get().prepare( 'SELECT * FROM meal_ingredients WHERE id = ?' @@ -318,17 +319,19 @@ router.patch('/ingredients/:ingId', (req, res) => { 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; + const { name, quantity, on_shopping_list, category } = req.body; db.get().prepare(` UPDATE meal_ingredients SET name = COALESCE(?, name), quantity = ?, + category = COALESCE(?, category), on_shopping_list = COALESCE(?, on_shopping_list) WHERE id = ? `).run( name?.trim() ?? null, quantity !== undefined ? (quantity?.trim() || null) : ing.quantity, + category !== undefined ? (String(category || '').trim() || 'Sonstiges') : null, on_shopping_list !== undefined ? (on_shopping_list ? 1 : 0) : null, ingId ); @@ -378,7 +381,7 @@ router.post('/:id/to-shopping-list', (req, res) => { 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; + const { listId } = req.body; if (!listId) return res.status(400).json({ error: 'listId ist erforderlich', code: 400 }); @@ -404,7 +407,7 @@ router.post('/:id/to-shopping-list', (req, res) => { let count = 0; for (const ing of ingredients) { - insertItem.run(listId, ing.name, ing.quantity, category, mealId); + insertItem.run(listId, ing.name, ing.quantity, ing.category || 'Sonstiges', mealId); markDone.run(ing.id); count++; } @@ -426,7 +429,7 @@ router.post('/:id/to-shopping-list', (req, res) => { */ router.post('/week-to-shopping-list', (req, res) => { try { - const { listId, week, category = 'Sonstiges' } = req.body; + const { listId, week } = req.body; if (!listId) return res.status(400).json({ error: 'listId ist erforderlich', code: 400 }); @@ -460,7 +463,7 @@ router.post('/week-to-shopping-list', (req, res) => { let count = 0; for (const ing of ingredients) { - insertItem.run(listId, ing.name, ing.quantity, category, ing.meal_id); + insertItem.run(listId, ing.name, ing.quantity, ing.category || 'Sonstiges', ing.meal_id); markDone.run(ing.id); count++; }