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
+ Ingredients
+
+
Instructions
+
+