diff --git a/CHANGELOG.md b/CHANGELOG.md
index 97ab076..efcaa4f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.13.0] - 2026-04-05
+
+### Added
+- Meals: optional recipe link per meal - add a URL in the meal modal and a link icon appears on the card for one-tap access to the recipe (#18)
+- Meals: `recipe_url` field stored in the database (migration v6)
+
## [0.12.0] - 2026-04-05
### Added
diff --git a/public/locales/de.json b/public/locales/de.json
index 9b917b5..2a0255f 100644
--- a/public/locales/de.json
+++ b/public/locales/de.json
@@ -234,7 +234,10 @@
"ingredientCount": "{{count}} Zutat",
"ingredientCountPlural": "{{count}} Zutaten",
"titleRequired": "Titel ist erforderlich",
- "loadingIndicator": "Lade…"
+ "loadingIndicator": "Lade…",
+ "recipeUrlLabel": "Rezept-Link (optional)",
+ "recipeUrlPlaceholder": "https://…",
+ "openRecipe": "Rezept öffnen"
},
"calendar": {
diff --git a/public/locales/en.json b/public/locales/en.json
index 353964e..249993a 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -234,7 +234,10 @@
"ingredientCount": "{{count}} ingredient",
"ingredientCountPlural": "{{count}} ingredients",
"titleRequired": "Title is required",
- "loadingIndicator": "Loading…"
+ "loadingIndicator": "Loading…",
+ "recipeUrlLabel": "Recipe link (optional)",
+ "recipeUrlPlaceholder": "https://…",
+ "openRecipe": "Open recipe"
},
"calendar": {
diff --git a/public/locales/it.json b/public/locales/it.json
index 51202c1..bc27378 100644
--- a/public/locales/it.json
+++ b/public/locales/it.json
@@ -234,7 +234,10 @@
"ingredientCount": "{{count}} ingrediente",
"ingredientCountPlural": "{{count}} ingredienti",
"titleRequired": "Il titolo è obbligatorio",
- "loadingIndicator": "Caricamento…"
+ "loadingIndicator": "Caricamento…",
+ "recipeUrlLabel": "Link ricetta (opzionale)",
+ "recipeUrlPlaceholder": "https://…",
+ "openRecipe": "Apri ricetta"
},
"calendar": {
diff --git a/public/locales/sv.json b/public/locales/sv.json
index 4f87a89..0630a78 100644
--- a/public/locales/sv.json
+++ b/public/locales/sv.json
@@ -234,7 +234,10 @@
"ingredientCount": "{{count}} ingrediens",
"ingredientCountPlural": "{{count}} ingredienser",
"titleRequired": "Titel krävs",
- "loadingIndicator": "Laddar…"
+ "loadingIndicator": "Laddar…",
+ "recipeUrlLabel": "Receptlänk (valfri)",
+ "recipeUrlPlaceholder": "https://…",
+ "openRecipe": "Öppna recept"
},
"calendar": {
diff --git a/public/pages/meals.js b/public/pages/meals.js
index 13dfde9..126c918 100644
--- a/public/pages/meals.js
+++ b/public/pages/meals.js
@@ -219,6 +219,13 @@ function renderSlot(date, type, mealsForDay) {
${ingLabel}${esc(ingDoneLabel)}
` : ''}
${ingRows}
@@ -623,6 +643,7 @@ async function saveModal(overlay) {
const meal_type = overlay.querySelector('#modal-type').value;
const title = overlay.querySelector('#modal-title').value.trim();
const notes = overlay.querySelector('#modal-notes').value.trim() || null;
+ const recipe_url = overlay.querySelector('#modal-recipe-url').value.trim() || null;
if (!title) {
window.oikos?.showToast(t('meals.titleRequired'), 'error');
@@ -643,11 +664,11 @@ async function saveModal(overlay) {
const { mode, meal } = state.modal;
if (mode === 'create') {
- const res = await api.post('/meals', { date, meal_type, title, notes, ingredients });
+ const res = await api.post('/meals', { date, meal_type, title, notes, recipe_url, ingredients });
state.meals.push(res.data);
} else {
// Update meal meta
- await api.put(`/meals/${meal.id}`, { date, meal_type, title, notes });
+ await api.put(`/meals/${meal.id}`, { date, meal_type, title, notes, recipe_url });
// Sync ingredients
const existingIds = new Set((meal.ingredients ?? []).map((i) => i.id));
diff --git a/public/styles/meals.css b/public/styles/meals.css
index d9fc5c5..fde8256 100644
--- a/public/styles/meals.css
+++ b/public/styles/meals.css
@@ -266,6 +266,14 @@
color: var(--color-success);
}
+.meal-card__action-btn--recipe {
+ text-decoration: none;
+}
+
+.meal-card__action-btn--recipe:hover {
+ color: var(--color-accent);
+}
+
/* --------------------------------------------------------
* Meals-Modal Content-Styles (Overlay/Panel via shared modal.js)
* -------------------------------------------------------- */
diff --git a/server/db.js b/server/db.js
index dc1ec57..92b6586 100644
--- a/server/db.js
+++ b/server/db.js
@@ -355,6 +355,13 @@ const MIGRATIONS = [
('Sonstiges', 'shopping-basket', 8);
`,
},
+ {
+ version: 6,
+ description: 'Rezept-URL für Mahlzeiten',
+ up: `
+ ALTER TABLE meals ADD COLUMN recipe_url TEXT;
+ `,
+ },
];
/**
diff --git a/server/routes/meals.js b/server/routes/meals.js
index 01ee1e3..612c628 100644
--- a/server/routes/meals.js
+++ b/server/routes/meals.js
@@ -151,19 +151,20 @@ router.get('/', (req, res) => {
router.post('/', (req, res) => {
try {
const { ingredients = [] } = req.body;
- const vDate = date(req.body.date, 'Datum', true);
- const vType = oneOf(req.body.meal_type, VALID_MEAL_TYPES, 'Mahlzeit-Typ');
- const vTitle = str(req.body.title, 'Titel', { max: MAX_TITLE });
- const vNotes = str(req.body.notes, 'Notizen', { max: MAX_TEXT, required: false });
- const errors = collectErrors([vDate, vType, vTitle, vNotes]);
+ const vDate = date(req.body.date, 'Datum', true);
+ const vType = oneOf(req.body.meal_type, VALID_MEAL_TYPES, 'Mahlzeit-Typ');
+ const vTitle = str(req.body.title, 'Titel', { max: MAX_TITLE });
+ const vNotes = str(req.body.notes, 'Notizen', { max: MAX_TEXT, required: false });
+ const vRecipeUrl = str(req.body.recipe_url, 'Rezept-URL', { max: MAX_TEXT, required: false });
+ const errors = collectErrors([vDate, vType, vTitle, vNotes, vRecipeUrl]);
if (!req.body.meal_type) errors.push('Mahlzeit-Typ ist erforderlich.');
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
const meal = db.transaction(() => {
const result = db.get().prepare(`
- INSERT INTO meals (date, meal_type, title, notes, created_by)
- VALUES (?, ?, ?, ?, ?)
- `).run(vDate.value, vType.value, vTitle.value, vNotes.value, req.session.userId);
+ INSERT INTO meals (date, meal_type, title, notes, recipe_url, created_by)
+ VALUES (?, ?, ?, ?, ?, ?)
+ `).run(vDate.value, vType.value, vTitle.value, vNotes.value, vRecipeUrl.value, req.session.userId);
const mealId = result.lastInsertRowid;
@@ -210,25 +211,28 @@ router.put('/:id', (req, res) => {
if (!meal) return res.status(404).json({ error: 'Mahlzeit nicht gefunden', code: 404 });
const checks = [];
- if (req.body.date !== undefined) checks.push(date(req.body.date, 'Datum'));
- if (req.body.meal_type !== undefined) checks.push(oneOf(req.body.meal_type, VALID_MEAL_TYPES, 'Mahlzeit-Typ'));
- if (req.body.title !== undefined) checks.push(str(req.body.title, 'Titel', { max: MAX_TITLE, required: false }));
- if (req.body.notes !== undefined) checks.push(str(req.body.notes, 'Notizen', { max: MAX_TEXT, required: false }));
+ if (req.body.date !== undefined) checks.push(date(req.body.date, 'Datum'));
+ if (req.body.meal_type !== undefined) checks.push(oneOf(req.body.meal_type, VALID_MEAL_TYPES, 'Mahlzeit-Typ'));
+ if (req.body.title !== undefined) checks.push(str(req.body.title, 'Titel', { max: MAX_TITLE, required: false }));
+ if (req.body.notes !== undefined) checks.push(str(req.body.notes, 'Notizen', { max: MAX_TEXT, required: false }));
+ if (req.body.recipe_url !== undefined) checks.push(str(req.body.recipe_url, 'Rezept-URL', { max: MAX_TEXT, required: false }));
const errors = collectErrors(checks);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
db.get().prepare(`
UPDATE meals
- SET date = COALESCE(?, date),
- meal_type = COALESCE(?, meal_type),
- title = COALESCE(?, title),
- notes = ?
+ SET date = COALESCE(?, date),
+ meal_type = COALESCE(?, meal_type),
+ title = COALESCE(?, title),
+ notes = ?,
+ recipe_url = ?
WHERE id = ?
`).run(
req.body.date ?? null,
req.body.meal_type ?? null,
req.body.title?.trim() ?? null,
- req.body.notes !== undefined ? (req.body.notes || null) : meal.notes,
+ req.body.notes !== undefined ? (req.body.notes || null) : meal.notes,
+ req.body.recipe_url !== undefined ? (req.body.recipe_url || null) : meal.recipe_url,
id
);