diff --git a/CHANGELOG.md b/CHANGELOG.md
index 950f210..b8a6c82 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.22.0] - 2026-04-21
+
+### Added
+- Recipes module: create, edit, duplicate, and delete reusable recipes with title, notes, a recipe link, and a per-ingredient category. Accessible via the new `/recipes` route and nav entry.
+- "Add to meal plan" action on recipe cards navigates to Meals and pre-fills the modal with the selected recipe.
+- Meals modal: select a saved recipe to auto-fill title, notes, URL, and ingredients; scale ingredient quantities by a numeric factor; save the current meal as a new recipe in one click.
+- `GET/POST /api/v1/recipes`, `PUT/DELETE /api/v1/recipes/:id` REST endpoints with full validation and ingredient sync.
+- Migration 13: `recipes` and `recipe_ingredients` tables; `recipe_id` FK column on `meals`.
+
## [0.21.1] - 2026-04-21
### Fixed
diff --git a/package-lock.json b/package-lock.json
index f287a80..f64d934 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "oikos",
- "version": "0.21.1",
+ "version": "0.22.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "oikos",
- "version": "0.21.1",
+ "version": "0.22.0",
"license": "MIT",
"dependencies": {
"bcrypt": "^6.0.0",
diff --git a/package.json b/package.json
index be73faf..4a47d5c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "oikos",
- "version": "0.21.1",
+ "version": "0.22.0",
"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/de.json b/public/locales/de.json
index c4ff9bc..69aa1dc 100644
--- a/public/locales/de.json
+++ b/public/locales/de.json
@@ -44,7 +44,7 @@
"navigation": "Navigation",
"quickActions": "Schnellaktionen",
"more": "Mehr",
- "recipes": "Recipes"
+ "recipes": "Rezepte"
},
"search": {
"title": "Suche",
@@ -258,10 +258,10 @@
"recipeUrlLabel": "Rezept-Link (optional)",
"recipeUrlPlaceholder": "https://…",
"openRecipe": "Rezept öffnen",
- "savedRecipeLabel": "Saved recipe",
- "savedRecipePlaceholder": "Select recipe",
- "saveAsRecipe": "Save as recipe",
- "recipeScaleLabel": "Scale ingredients"
+ "savedRecipeLabel": "Gespeichertes Rezept",
+ "savedRecipePlaceholder": "Rezept auswählen",
+ "saveAsRecipe": "Als Rezept speichern",
+ "recipeScaleLabel": "Zutaten skalieren"
},
"calendar": {
"title": "Kalender",
@@ -683,27 +683,27 @@
"pendingBadgeTitlePlural": "{{count}} fällige Erinnerungen"
},
"recipes": {
- "title": "Recipes",
- "addRecipe": "Add recipe",
- "editRecipe": "Edit recipe",
- "emptyTitle": "No recipes yet",
- "emptyDescription": "Save your favorite recipes and reuse them in meal planning.",
- "titleLabel": "Title *",
- "titlePlaceholder": "e.g. Pasta Carbonara",
- "notesLabel": "Notes",
- "notesPlaceholder": "Optional...",
- "urlLabel": "Recipe link",
- "urlPlaceholder": "https://...",
- "ingredientsLabel": "Ingredients",
- "addToMeals": "Add to meal plan",
- "openLink": "Open recipe link",
- "deleteConfirm": "Delete recipe \"{{title}}\"?",
- "created": "Recipe saved.",
- "updated": "Recipe updated.",
- "deleted": "Recipe deleted.",
- "titleRequired": "Title is required",
- "duplicate": "Duplicate",
- "duplicated": "Recipe duplicated.",
- "copySuffix": "copy"
+ "title": "Rezepte",
+ "addRecipe": "Rezept hinzufügen",
+ "editRecipe": "Rezept bearbeiten",
+ "emptyTitle": "Noch keine Rezepte",
+ "emptyDescription": "Speichere deine Lieblingsrezepte und nutze sie für die Essensplanung.",
+ "titleLabel": "Titel *",
+ "titlePlaceholder": "z. B. Pasta Carbonara",
+ "notesLabel": "Notizen",
+ "notesPlaceholder": "Optional…",
+ "urlLabel": "Rezept-Link",
+ "urlPlaceholder": "https://…",
+ "ingredientsLabel": "Zutaten",
+ "addToMeals": "In Essensplan übernehmen",
+ "openLink": "Rezept-Link öffnen",
+ "deleteConfirm": "Rezept „{{title}}" löschen?",
+ "created": "Rezept gespeichert.",
+ "updated": "Rezept aktualisiert.",
+ "deleted": "Rezept gelöscht.",
+ "titleRequired": "Titel ist erforderlich.",
+ "duplicate": "Duplizieren",
+ "duplicated": "Rezept dupliziert.",
+ "copySuffix": "Kopie"
}
}
diff --git a/public/pages/recipes.js b/public/pages/recipes.js
index d790a73..48e85cd 100644
--- a/public/pages/recipes.js
+++ b/public/pages/recipes.js
@@ -5,7 +5,6 @@
import { api } from '/api.js';
import { t } from '/i18n.js';
-import { esc } from '/utils/html.js';
import { openModal as openSharedModal, closeModal as closeSharedModal, confirmModal } from '/components/modal.js';
import { DEFAULT_CATEGORY_NAME, categoryLabel } from '/utils/shopping-categories.js';
@@ -211,32 +210,63 @@ function renderRecipeList() {
}
}
-function recipeIngredientRowHTML(name, qty, category = DEFAULT_CATEGORY_NAME) {
+function buildIngredientRow(name, qty, category = DEFAULT_CATEGORY_NAME) {
const categories = mealCategories();
const resolvedCategory = categories.some((c) => c.name === category)
? category
: (categories[0]?.name ?? DEFAULT_CATEGORY_NAME);
- const catOptions = categories.length
- ? categories.map((c) => ``).join('')
- : ``;
- return `
-
-
-
-
-
-
- `;
+ const row = document.createElement('div');
+ row.className = 'recipe-ingredient-row';
+
+ const nameInput = document.createElement('input');
+ nameInput.type = 'text';
+ nameInput.className = 'form-input recipe-ingredient-row__name';
+ nameInput.placeholder = t('meals.ingredientNamePlaceholder');
+ nameInput.value = name;
+
+ const qtyInput = document.createElement('input');
+ qtyInput.type = 'text';
+ qtyInput.className = 'form-input recipe-ingredient-row__qty';
+ qtyInput.placeholder = t('meals.ingredientQtyPlaceholder');
+ qtyInput.value = qty;
+
+ const catSelect = document.createElement('select');
+ catSelect.className = 'form-input recipe-ingredient-row__cat';
+ catSelect.setAttribute('aria-label', t('meals.ingredientCategoryLabel'));
+ if (categories.length) {
+ for (const c of categories) {
+ const opt = document.createElement('option');
+ opt.value = c.name;
+ opt.textContent = categoryLabel(c.name);
+ if (c.name === resolvedCategory) opt.selected = true;
+ catSelect.appendChild(opt);
+ }
+ } else {
+ const opt = document.createElement('option');
+ opt.value = DEFAULT_CATEGORY_NAME;
+ opt.textContent = t('meals.ingredientCategoryDefault');
+ opt.selected = true;
+ catSelect.appendChild(opt);
+ }
+
+ const removeBtn = document.createElement('button');
+ removeBtn.className = 'recipe-ingredient-row__remove';
+ removeBtn.dataset.action = 'remove-ingredient';
+ removeBtn.type = 'button';
+ removeBtn.setAttribute('aria-label', t('meals.removeIngredient'));
+ const icon = document.createElement('i');
+ icon.dataset.lucide = 'x';
+ icon.style.cssText = 'width:14px;height:14px;';
+ icon.setAttribute('aria-hidden', 'true');
+ removeBtn.appendChild(icon);
+
+ row.append(nameInput, qtyInput, catSelect, removeBtn);
+ return row;
}
function openRecipeModal(mode, recipe = null) {
const isEdit = mode === 'edit';
- const ingredientRows = isEdit && recipe.ingredients?.length
- ? recipe.ingredients.map((i) => recipeIngredientRowHTML(i.name, i.quantity ?? '', i.category ?? DEFAULT_CATEGORY_NAME)).join('')
- : '';
openSharedModal({
title: isEdit ? t('recipes.editRecipe') : t('recipes.addRecipe'),
@@ -244,19 +274,19 @@ function openRecipeModal(mode, recipe = null) {
content: `
-
+
-
+
-
+
`,
onSave(panel) {
+ panel.querySelector('#recipe-title').value = isEdit ? recipe.title : '';
+ panel.querySelector('#recipe-notes').value = isEdit && recipe.notes ? recipe.notes : '';
+ panel.querySelector('#recipe-url').value = isEdit && recipe.recipe_url ? recipe.recipe_url : '';
+
const ingList = panel.querySelector('#recipe-ingredient-list');
+ if (isEdit && recipe.ingredients?.length) {
+ for (const i of recipe.ingredients) {
+ ingList.appendChild(buildIngredientRow(i.name, i.quantity ?? '', i.category ?? DEFAULT_CATEGORY_NAME));
+ }
+ }
+
panel.querySelector('#recipe-add-ingredient')?.addEventListener('click', () => {
- const tmp = document.createElement('div');
- tmp.innerHTML = recipeIngredientRowHTML('', '', null);
- const row = tmp.firstElementChild;
- ingList.appendChild(row);
+ ingList.appendChild(buildIngredientRow('', '', null));
if (window.lucide) window.lucide.createIcons();
});
@@ -362,8 +399,8 @@ async function duplicateRecipe(recipe) {
}));
try {
- await api.post('/recipes', { title, notes, recipe_url, ingredients });
- await loadRecipes();
+ const res = await api.post('/recipes', { title, notes, recipe_url, ingredients });
+ state.recipes.push(res.data);
renderRecipeList();
window.oikos?.showToast(t('recipes.duplicated'), 'success');
} catch (err) {
diff --git a/public/router.js b/public/router.js
index d189621..9cf46f1 100644
--- a/public/router.js
+++ b/public/router.js
@@ -582,7 +582,7 @@ function navItems() {
{ path: '/tasks', label: t('nav.tasks'), icon: 'check-square' },
{ path: '/calendar', label: t('nav.calendar'), icon: 'calendar' },
{ path: '/meals', label: t('nav.meals'), icon: 'utensils' },
- { path: '/recipes', label: t('nav.recipes'), icon: 'book-open-text' },
+ { path: '/recipes', label: t('nav.recipes'), icon: 'book-text' },
{ path: '/shopping', label: t('nav.shopping'), icon: 'shopping-cart' },
{ path: '/notes', label: t('nav.notes'), icon: 'sticky-note' },
{ path: '/contacts', label: t('nav.contacts'), icon: 'book-user' },
diff --git a/server/db-schema-test.js b/server/db-schema-test.js
index c370072..03f9024 100644
--- a/server/db-schema-test.js
+++ b/server/db-schema-test.js
@@ -233,6 +233,11 @@ const MIGRATIONS_SQL = {
CREATE INDEX IF NOT EXISTS idx_calendar_sub ON calendar_events(subscription_id);
`,
12: `
+ DROP INDEX IF EXISTS idx_calendar_sub_extid;
+ CREATE UNIQUE INDEX idx_calendar_sub_extid
+ ON calendar_events (subscription_id, external_calendar_id);
+ `,
+ 13: `
CREATE TABLE IF NOT EXISTS recipes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
diff --git a/server/routes/recipes.js b/server/routes/recipes.js
index 462b4f4..e301bd3 100644
--- a/server/routes/recipes.js
+++ b/server/routes/recipes.js
@@ -110,8 +110,9 @@ router.put('/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
if (!id) return res.status(400).json({ error: 'Ungueltige Rezept-ID', code: 400 });
- const existing = db.get().prepare('SELECT id FROM recipes WHERE id = ?').get(id);
+ const existing = db.get().prepare('SELECT id, created_by FROM recipes WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: 'Rezept nicht gefunden', code: 404 });
+ if (existing.created_by !== req.session.userId) return res.status(403).json({ error: 'Nicht autorisiert.', code: 403 });
const { ingredients = [] } = req.body;
@@ -154,8 +155,11 @@ router.put('/:id', (req, res) => {
router.delete('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
- const existing = num(id, 'Rezept-ID', { required: true });
- if (existing.error) return res.status(400).json({ error: existing.error, code: 400 });
+ if (!id) return res.status(400).json({ error: 'Ungültige Rezept-ID.', code: 400 });
+
+ const existing = db.get().prepare('SELECT id, created_by FROM recipes WHERE id = ?').get(id);
+ if (!existing) return res.status(404).json({ error: 'Rezept nicht gefunden.', code: 404 });
+ if (existing.created_by !== req.session.userId) return res.status(403).json({ error: 'Nicht autorisiert.', code: 403 });
const result = db.get().prepare('DELETE FROM recipes WHERE id = ?').run(id);
if (result.changes === 0) return res.status(404).json({ error: 'Rezept nicht gefunden', code: 404 });