diff --git a/MMM-NextcloudCookbook.css b/MMM-NextcloudCookbook.css index 159df6c..0671174 100644 --- a/MMM-NextcloudCookbook.css +++ b/MMM-NextcloudCookbook.css @@ -117,6 +117,44 @@ margin-bottom: calc(18px * var(--ncc-content-scale)); } +.MMM-NextcloudCookbook .ncc-progress-control { + display: flex; + gap: calc(16px * var(--ncc-content-scale)); + align-items: flex-start; + cursor: pointer; +} + +.MMM-NextcloudCookbook .ncc-progress-control input { + width: calc(40px * var(--ncc-content-scale)); + height: calc(40px * var(--ncc-content-scale)); + min-width: 28px; + min-height: 28px; + margin: calc(10px * var(--ncc-content-scale)) 0 0; + accent-color: #8fda9a; + cursor: pointer; +} + +.MMM-NextcloudCookbook .ncc-quantity-warning { + color: #ffd66b; +} + +.MMM-NextcloudCookbook .ncc-quantity-warning::marker { + content: "⚠ "; +} + +.MMM-NextcloudCookbook .ncc-quantity-error { + color: #ff9d91; +} + +.MMM-NextcloudCookbook .ncc-quantity-error::marker { + content: "! "; +} + +.MMM-NextcloudCookbook .ncc-completed { + color: #a8a79f; + text-decoration: line-through; +} + .MMM-NextcloudCookbook .ncc-error { color: #ff9d91; font-size: 30px; diff --git a/MMM-NextcloudCookbook.js b/MMM-NextcloudCookbook.js index 712e7d0..c5ef371 100644 --- a/MMM-NextcloudCookbook.js +++ b/MMM-NextcloudCookbook.js @@ -123,11 +123,11 @@ Module.register("MMM-NextcloudCookbook", { return root; } - root.appendChild(this.renderRecipe(this.state.recipe)); + root.appendChild(this.renderRecipe(this.state.recipe, this.state.progress)); return root; }, - renderRecipe(recipe) { + renderRecipe(recipe, progress = {}) { const article = document.createElement("article"); article.className = "ncc-recipe"; @@ -138,6 +138,7 @@ Module.register("MMM-NextcloudCookbook", { const metadata = [ ["Yield", recipe.recipeYield], + ["Original yield", recipe.originalRecipeYield !== recipe.recipeYield ? recipe.originalRecipeYield : null], ["Prep", NCCFormatDuration(recipe.prepTime)], ["Cook", NCCFormatDuration(recipe.cookTime)], ["Total", NCCFormatDuration(recipe.totalTime)] @@ -168,9 +169,34 @@ Module.register("MMM-NextcloudCookbook", { ingredientsTitle.textContent = "Ingredients"; ingredients.appendChild(ingredientsTitle); const ingredientList = document.createElement("ul"); - (recipe.recipeIngredient || []).forEach((ingredient) => { + (recipe.recipeIngredient || []).forEach((ingredient, index) => { const item = document.createElement("li"); - item.textContent = ingredient; + const status = recipe.recipeIngredientStatus?.[index]; + if (status === "unquantified") { + item.classList.add("ncc-quantity-warning"); + item.setAttribute("aria-label", `Verify quantity: ${ingredient}`); + } else if (status === "unscalable" || status === "approximate") { + item.classList.add("ncc-quantity-error"); + item.setAttribute("aria-label", `Scaling warning: ${ingredient}`); + } + const completed = progress.completedIngredients?.includes(index); + if (completed) item.classList.add("ncc-completed"); + const label = document.createElement("label"); + label.className = "ncc-progress-control"; + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = completed; + checkbox.setAttribute("aria-label", `Mark ingredient complete: ${ingredient}`); + checkbox.addEventListener("change", () => { + checkbox.disabled = true; + this.sendSocketNotification("UPDATE_PROGRESS", { + kind: "ingredient", index, completed: checkbox.checked + }); + }); + const text = document.createElement("span"); + text.textContent = ingredient; + label.append(checkbox, text); + item.appendChild(label); ingredientList.appendChild(item); }); ingredients.appendChild(ingredientList); @@ -180,9 +206,26 @@ Module.register("MMM-NextcloudCookbook", { instructionsTitle.textContent = "Instructions"; instructions.appendChild(instructionsTitle); const instructionList = document.createElement("ol"); - this.flattenInstructions(recipe.recipeInstructions || []).forEach((step) => { + this.flattenInstructions(recipe.recipeInstructions || []).forEach((step, index) => { const item = document.createElement("li"); - item.textContent = step; + const completed = progress.completedSteps?.includes(index); + if (completed) item.classList.add("ncc-completed"); + const label = document.createElement("label"); + label.className = "ncc-progress-control"; + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = completed; + checkbox.setAttribute("aria-label", `Mark step complete: ${step}`); + checkbox.addEventListener("change", () => { + checkbox.disabled = true; + this.sendSocketNotification("UPDATE_PROGRESS", { + kind: "step", index, completed: checkbox.checked + }); + }); + const text = document.createElement("span"); + text.textContent = step; + label.append(checkbox, text); + item.appendChild(label); instructionList.appendChild(item); }); instructions.appendChild(instructionList); diff --git a/README.md b/README.md index 668f89d..389b577 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,10 @@ A MagicMirror module that displays recipes from a read-only Nextcloud public share. It includes a phone-friendly control page for choosing a recipe, -adjusting its number of portions, and entering or leaving recipe mode. +adjusting its number of portions, marking ingredients and steps complete, and +entering or leaving recipe mode. Completion marks are shared with the mirror +and retained until a different recipe is selected, so a recipe can be resumed +after exiting recipe mode. Create a public share for the folder configured as your Nextcloud Cookbook recipe directory. Read-only permission is sufficient. Copy the public share @@ -30,6 +33,11 @@ the best use of the available screen while keeping all recipe content visible. The recipe title, timing metadata, yield, and QR controller remain at fixed sizes. +When portions change, the display shows both the selected yield and the +recipe's original yield. Ingredients without a leading quantity are marked in +yellow because they cannot be scaled; unsupported or imprecise numeric scaling +is marked in red. + The configured `position` controls where the QR controller appears during the normal MagicMirror layout. When a recipe is activated, the module displays a fixed full-screen overlay regardless of that region. Exiting recipe mode diff --git a/control/app.js b/control/app.js index 3c3b00c..ed059c1 100644 --- a/control/app.js +++ b/control/app.js @@ -7,10 +7,18 @@ const portionControls = document.querySelector("#portions"); const portionCount = document.querySelector("#portion-count"); const fewerPortions = document.querySelector("#fewer-portions"); const morePortions = document.querySelector("#more-portions"); +const activeRecipe = document.querySelector("#active-recipe"); +const activeRecipeName = document.querySelector("#active-recipe-name"); +const activeRecipeYield = document.querySelector("#active-recipe-yield"); +const activeIngredients = document.querySelector("#active-ingredients"); +const activeInstructions = document.querySelector("#active-instructions"); let recipes = []; let activeRecipeId = null; -let activeRecipeName = null; +let activeRecipeLabel = null; +let displayedRecipe = null; let portions = null; +let progress = { completedIngredients: [], completedSteps: [] }; +let recipeModeActive = false; async function request(path, options) { const response = await fetch(`${apiBase}${path}`, { @@ -45,9 +53,65 @@ function render() { return item; })); if (!matches.length) status.textContent = "No matching recipes."; - portionControls.hidden = !activeRecipeId; + portionControls.hidden = !recipeModeActive; portionCount.textContent = portions ?? ""; fewerPortions.disabled = portions <= 1; + renderActiveRecipe(); +} + +function flattenInstructions(instructions) { + return instructions.flatMap((instruction) => { + if (typeof instruction === "string") return [instruction]; + if (Array.isArray(instruction?.itemListElement)) return flattenInstructions(instruction.itemListElement); + return instruction?.text ? [instruction.text] : []; + }); +} + +function renderChecklist(container, items, completed, kind, statuses = []) { + container.replaceChildren(...items.map((item, index) => { + const row = document.createElement("li"); + const label = document.createElement("label"); + const check = document.createElement("input"); + check.type = "checkbox"; + check.checked = completed.includes(index); + check.addEventListener("change", () => updateProgress(kind, index, check.checked)); + const text = document.createElement("span"); + text.textContent = item; + if (check.checked) row.classList.add("completed"); + if (statuses[index] === "unquantified") { + row.classList.add("quantity-warning"); + text.prepend("⚠ Verify quantity: "); + } else if (["unscalable", "approximate"].includes(statuses[index])) { + row.classList.add("quantity-error"); + text.prepend("! Scaling warning: "); + } + label.append(check, text); + row.appendChild(label); + return row; + })); +} + +function renderActiveRecipe() { + const recipe = displayedRecipe; + activeRecipe.hidden = !recipe; + if (!recipe) return; + activeRecipeName.textContent = recipe.name || "Untitled recipe"; + const original = recipe.originalRecipeYield; + activeRecipeYield.textContent = original && original !== recipe.recipeYield + ? `Yield: ${recipe.recipeYield} (original: ${original})` + : `Yield: ${recipe.recipeYield || "Not specified"}`; + renderChecklist(activeIngredients, recipe.recipeIngredient || [], progress.completedIngredients, "ingredient", + recipe.recipeIngredientStatus || []); + renderChecklist(activeInstructions, flattenInstructions(recipe.recipeInstructions || []), progress.completedSteps, "step"); +} + +function applyState(state) { + recipeModeActive = Boolean(state.active); + activeRecipeId = recipeModeActive ? state.recipeId : null; + activeRecipeLabel = state.recipe?.name || activeRecipeLabel; + displayedRecipe = recipeModeActive ? state.recipe : null; + portions = state.portions; + progress = state.progress || { completedIngredients: [], completedSteps: [] }; } async function activate(recipe) { @@ -57,9 +121,8 @@ async function activate(recipe) { method: "POST", body: JSON.stringify({ recipeId: recipe.id }) }); - activeRecipeId = state.recipeId; - activeRecipeName = recipe.name; - portions = state.portions; + applyState(state); + activeRecipeLabel = recipe.name; status.textContent = `${recipe.name} is now on the mirror.`; render(); } catch (error) { @@ -73,24 +136,36 @@ async function adjustPortions(delta) { method: "POST", body: JSON.stringify({ delta }) }); - portions = state.portions; - status.textContent = `${activeRecipeName} is now on the mirror.`; + applyState(state); + status.textContent = `${activeRecipeLabel} is now on the mirror.`; render(); } catch (error) { status.textContent = error.message; } } +async function updateProgress(kind, index, completed) { + try { + const state = await request("/progress", { + method: "POST", + body: JSON.stringify({ kind, index, completed }) + }); + applyState(state); + render(); + } catch (error) { + status.textContent = error.message; + render(); + } +} + fewerPortions.addEventListener("click", () => adjustPortions(-1)); morePortions.addEventListener("click", () => adjustPortions(1)); exit.addEventListener("click", async () => { status.textContent = "Leaving recipe mode…"; try { - await request("/exit", { method: "POST", body: "{}" }); - activeRecipeId = null; - activeRecipeName = null; - portions = null; + const state = await request("/exit", { method: "POST", body: "{}" }); + applyState(state); status.textContent = "The mirror is back to its normal display."; render(); } catch (error) { @@ -102,9 +177,7 @@ search.addEventListener("input", render); Promise.all([request("/state"), request("/recipes")]) .then(([state, loadedRecipes]) => { - activeRecipeId = state.recipeId; - activeRecipeName = state.recipe?.name || null; - portions = state.portions; + applyState(state); recipes = loadedRecipes; status.textContent = `${recipes.length} recipes available.`; render(); diff --git a/control/index.html b/control/index.html index 86559e6..ae3cd59 100644 --- a/control/index.html +++ b/control/index.html @@ -21,6 +21,18 @@ portions + diff --git a/control/styles.css b/control/styles.css index aebd31f..18b5e24 100644 --- a/control/styles.css +++ b/control/styles.css @@ -102,6 +102,30 @@ button { .recipe strong { font-size: 1.12rem; } +.active-recipe { + margin: 1.5rem 0; + padding: 1rem; + border: 1px solid #d8c9ad; + border-radius: 8px; + background: #fffdf8; +} + +.active-recipe h2, +.active-recipe h3 { margin-top: 0; } + +.checklist { + display: grid; + gap: .7rem; + padding-left: 1.4rem; +} + +.checklist li { padding-left: .2rem; } +.checklist label { display: flex; gap: .6rem; align-items: flex-start; } +.checklist input { margin-top: .25rem; min-width: 1.1rem; min-height: 1.1rem; } +.checklist .completed { color: #777; text-decoration: line-through; } +.checklist .quantity-warning { color: #876500; } +.checklist .quantity-error { color: #a2342b; } + .recipe span { display: -webkit-box; overflow: hidden; diff --git a/lib/recipe-progress.js b/lib/recipe-progress.js new file mode 100644 index 0000000..ff6bdee --- /dev/null +++ b/lib/recipe-progress.js @@ -0,0 +1,35 @@ +function flattenInstructions(instructions) { + return instructions.flatMap((instruction) => { + if (typeof instruction === "string") return [instruction]; + if (Array.isArray(instruction?.itemListElement)) return flattenInstructions(instruction.itemListElement); + return instruction?.text ? [instruction.text] : []; + }); +} + +function normalizeProgress(progress = {}) { + return { + completedIngredients: [...new Set((progress.completedIngredients || []).filter(Number.isInteger))], + completedSteps: [...new Set((progress.completedSteps || []).filter(Number.isInteger))] + }; +} + +function updateProgress(recipe, progress, { kind, index, completed }) { + if (!recipe || !["ingredient", "step"].includes(kind) + || !Number.isInteger(index) || typeof completed !== "boolean") { + throw new Error("Invalid progress update"); + } + const limit = kind === "ingredient" + ? (recipe.recipeIngredient || []).length + : flattenInstructions(recipe.recipeInstructions || []).length; + if (index < 0 || index >= limit) throw new Error("Progress item does not exist"); + + const key = kind === "ingredient" ? "completedIngredients" : "completedSteps"; + const next = normalizeProgress(progress); + const items = new Set(next[key]); + if (completed) items.add(index); + else items.delete(index); + next[key] = [...items].sort((left, right) => left - right); + return next; +} + +module.exports = { flattenInstructions, normalizeProgress, updateProgress }; diff --git a/node_helper.js b/node_helper.js index 34efeba..4219f51 100644 --- a/node_helper.js +++ b/node_helper.js @@ -5,11 +5,15 @@ const NodeHelper = require("node_helper"); const QRCode = require("qrcode"); const NextcloudPublicShare = require("./lib/nextcloud-public-share"); const { parseYield, scaleRecipe } = require("./lib/recipe-scaler"); +const { normalizeProgress, updateProgress } = require("./lib/recipe-progress"); module.exports = NodeHelper.create({ start() { this.config = { controlPath: "/MMM-NextcloudCookbook/control" }; - this.state = { active: false, recipeId: null, recipe: null, portions: null, error: null }; + this.state = { + active: false, recipeId: null, recipe: null, portions: null, + progress: normalizeProgress(), error: null + }; this.recipeCache = { expires: 0, recipes: [] }; this.stateFile = path.join(process.cwd(), "config", "MMM-NextcloudCookbook-state.json"); this.configureClient(); @@ -18,6 +22,10 @@ module.exports = NodeHelper.create({ }, socketNotificationReceived(notification, payload) { + if (notification === "UPDATE_PROGRESS") { + this.updateProgressFromMirror(payload); + return; + } if (notification !== "CONFIG") return; this.config = { ...this.config, ...payload }; try { @@ -82,11 +90,17 @@ module.exports = NodeHelper.create({ return response.status(400).json({ error: "recipeId is required" }); } const recipe = await this.getClient().getRecipe(recipeId); + const isResumingRecipe = this.state.recipeId === recipeId + && Number.isInteger(this.state.portions) + && this.state.portions > 0; this.state = { active: true, recipeId, recipe, - portions: Math.max(1, Math.round(parseYield(recipe.recipeYield) || 1)), + portions: isResumingRecipe + ? this.state.portions + : Math.max(1, Math.round(parseYield(recipe.recipeYield) || 1)), + progress: isResumingRecipe ? this.state.progress : normalizeProgress(), error: null }; await this.saveState(); @@ -113,9 +127,25 @@ module.exports = NodeHelper.create({ return next(error); } }); + this.expressApp.post("/MMM-NextcloudCookbook/api/progress", async (request, response, next) => { + try { + if (!this.state.active || !this.state.recipe) { + return response.status(409).json({ error: "No recipe is active" }); + } + this.state.progress = updateProgress(this.state.recipe, this.state.progress, request.body || {}); + await this.saveState(); + this.sendSocketNotification("STATE", this.publicState()); + return response.json(this.publicState()); + } catch (error) { + if (error.message === "Invalid progress update" || error.message === "Progress item does not exist") { + return response.status(400).json({ error: error.message }); + } + return next(error); + } + }); this.expressApp.post("/MMM-NextcloudCookbook/api/exit", async (_request, response, next) => { try { - this.state = { active: false, recipeId: null, recipe: null, portions: null, error: null }; + this.state = { ...this.state, active: false, error: null }; await this.saveState(); this.sendSocketNotification("STATE", this.publicState()); response.json(this.publicState()); @@ -144,12 +174,13 @@ module.exports = NodeHelper.create({ async loadState() { try { const persisted = JSON.parse(await fs.readFile(this.stateFile, "utf8")); - if (!persisted.active || !persisted.recipeId) return; + if (!persisted.recipeId) return; this.state = { - active: true, + active: Boolean(persisted.active), recipeId: persisted.recipeId, recipe: null, portions: Number.isInteger(persisted.portions) ? persisted.portions : null, + progress: normalizeProgress(persisted.progress), error: null }; } catch (error) { @@ -174,7 +205,8 @@ module.exports = NodeHelper.create({ await fs.writeFile(temporary, `${JSON.stringify({ active: this.state.active, recipeId: this.state.recipeId, - portions: this.state.portions + portions: this.state.portions, + progress: this.state.progress })}\n`, { mode: 0o600 }); @@ -190,9 +222,24 @@ module.exports = NodeHelper.create({ return { active: this.state.active, recipeId: this.state.recipeId, - recipe: this.state.recipe ? scaleRecipe(this.state.recipe, this.state.portions) : null, + recipe: this.state.active && this.state.recipe + ? scaleRecipe(this.state.recipe, this.state.portions) + : null, portions: this.state.portions, + progress: this.state.progress, error: this.state.error }; + }, + + async updateProgressFromMirror(payload) { + try { + if (!this.state.active || !this.state.recipe) return; + this.state.progress = updateProgress(this.state.recipe, this.state.progress, payload || {}); + await this.saveState(); + this.sendSocketNotification("STATE", this.publicState()); + } catch (error) { + console.error(`[MMM-NextcloudCookbook] ${error.message}`); + this.sendSocketNotification("STATE", this.publicState()); + } } }); diff --git a/package.json b/package.json index b7be08a..34a4791 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "description": "Display recipes from a Nextcloud Cookbook share on MagicMirror", "main": "node_helper.js", "scripts": { - "check": "node --check MMM-NextcloudCookbook.js && node --check node_helper.js && node --check lib/format-duration.js && node --check lib/nextcloud-public-share.js && node --check lib/recipe-scaler.js && node --check control/app.js", + "check": "node --check MMM-NextcloudCookbook.js && node --check node_helper.js && node --check lib/format-duration.js && node --check lib/nextcloud-public-share.js && node --check lib/recipe-progress.js && node --check lib/recipe-scaler.js && node --check control/app.js", "test": "npm run check && node --test" }, "engines": { diff --git a/test/recipe-progress.test.js b/test/recipe-progress.test.js new file mode 100644 index 0000000..2fa9df0 --- /dev/null +++ b/test/recipe-progress.test.js @@ -0,0 +1,25 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { flattenInstructions, updateProgress } = require("../lib/recipe-progress"); + +const recipe = { + recipeIngredient: ["1 cup flour", "1 egg"], + recipeInstructions: ["Mix", { itemListElement: [{ text: "Bake" }] }] +}; + +test("flattens nested recipe instructions", () => { + assert.deepEqual(flattenInstructions(recipe.recipeInstructions), ["Mix", "Bake"]); +}); + +test("updates and validates cooking progress", () => { + const ingredientProgress = updateProgress(recipe, {}, { + kind: "ingredient", index: 1, completed: true + }); + assert.deepEqual(ingredientProgress, { completedIngredients: [1], completedSteps: [] }); + assert.deepEqual(updateProgress(recipe, ingredientProgress, { + kind: "ingredient", index: 1, completed: false + }), { completedIngredients: [], completedSteps: [] }); + assert.throws(() => updateProgress(recipe, {}, { + kind: "step", index: 2, completed: true + }), /does not exist/); +});