feat: synchronize recipe completion controls

Fixes #5\nRefs #1, #2
This commit is contained in:
2026-08-14 13:28:57 -05:00
parent b61ca70bed
commit 6f28f83f4a
10 changed files with 334 additions and 29 deletions
+38
View File
@@ -117,6 +117,44 @@
margin-bottom: calc(18px * var(--ncc-content-scale)); 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 { .MMM-NextcloudCookbook .ncc-error {
color: #ff9d91; color: #ff9d91;
font-size: 30px; font-size: 30px;
+49 -6
View File
@@ -123,11 +123,11 @@ Module.register("MMM-NextcloudCookbook", {
return root; return root;
} }
root.appendChild(this.renderRecipe(this.state.recipe)); root.appendChild(this.renderRecipe(this.state.recipe, this.state.progress));
return root; return root;
}, },
renderRecipe(recipe) { renderRecipe(recipe, progress = {}) {
const article = document.createElement("article"); const article = document.createElement("article");
article.className = "ncc-recipe"; article.className = "ncc-recipe";
@@ -138,6 +138,7 @@ Module.register("MMM-NextcloudCookbook", {
const metadata = [ const metadata = [
["Yield", recipe.recipeYield], ["Yield", recipe.recipeYield],
["Original yield", recipe.originalRecipeYield !== recipe.recipeYield ? recipe.originalRecipeYield : null],
["Prep", NCCFormatDuration(recipe.prepTime)], ["Prep", NCCFormatDuration(recipe.prepTime)],
["Cook", NCCFormatDuration(recipe.cookTime)], ["Cook", NCCFormatDuration(recipe.cookTime)],
["Total", NCCFormatDuration(recipe.totalTime)] ["Total", NCCFormatDuration(recipe.totalTime)]
@@ -168,9 +169,34 @@ Module.register("MMM-NextcloudCookbook", {
ingredientsTitle.textContent = "Ingredients"; ingredientsTitle.textContent = "Ingredients";
ingredients.appendChild(ingredientsTitle); ingredients.appendChild(ingredientsTitle);
const ingredientList = document.createElement("ul"); const ingredientList = document.createElement("ul");
(recipe.recipeIngredient || []).forEach((ingredient) => { (recipe.recipeIngredient || []).forEach((ingredient, index) => {
const item = document.createElement("li"); 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); ingredientList.appendChild(item);
}); });
ingredients.appendChild(ingredientList); ingredients.appendChild(ingredientList);
@@ -180,9 +206,26 @@ Module.register("MMM-NextcloudCookbook", {
instructionsTitle.textContent = "Instructions"; instructionsTitle.textContent = "Instructions";
instructions.appendChild(instructionsTitle); instructions.appendChild(instructionsTitle);
const instructionList = document.createElement("ol"); const instructionList = document.createElement("ol");
this.flattenInstructions(recipe.recipeInstructions || []).forEach((step) => { this.flattenInstructions(recipe.recipeInstructions || []).forEach((step, index) => {
const item = document.createElement("li"); 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); instructionList.appendChild(item);
}); });
instructions.appendChild(instructionList); instructions.appendChild(instructionList);
+9 -1
View File
@@ -2,7 +2,10 @@
A MagicMirror module that displays recipes from a read-only Nextcloud public A MagicMirror module that displays recipes from a read-only Nextcloud public
share. It includes a phone-friendly control page for choosing a recipe, 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 Create a public share for the folder configured as your Nextcloud Cookbook
recipe directory. Read-only permission is sufficient. Copy the public share 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 The recipe title, timing metadata, yield, and QR controller remain at fixed
sizes. 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 The configured `position` controls where the QR controller appears during the
normal MagicMirror layout. When a recipe is activated, the module displays a normal MagicMirror layout. When a recipe is activated, the module displays a
fixed full-screen overlay regardless of that region. Exiting recipe mode fixed full-screen overlay regardless of that region. Exiting recipe mode
+87 -14
View File
@@ -7,10 +7,18 @@ const portionControls = document.querySelector("#portions");
const portionCount = document.querySelector("#portion-count"); const portionCount = document.querySelector("#portion-count");
const fewerPortions = document.querySelector("#fewer-portions"); const fewerPortions = document.querySelector("#fewer-portions");
const morePortions = document.querySelector("#more-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 recipes = [];
let activeRecipeId = null; let activeRecipeId = null;
let activeRecipeName = null; let activeRecipeLabel = null;
let displayedRecipe = null;
let portions = null; let portions = null;
let progress = { completedIngredients: [], completedSteps: [] };
let recipeModeActive = false;
async function request(path, options) { async function request(path, options) {
const response = await fetch(`${apiBase}${path}`, { const response = await fetch(`${apiBase}${path}`, {
@@ -45,9 +53,65 @@ function render() {
return item; return item;
})); }));
if (!matches.length) status.textContent = "No matching recipes."; if (!matches.length) status.textContent = "No matching recipes.";
portionControls.hidden = !activeRecipeId; portionControls.hidden = !recipeModeActive;
portionCount.textContent = portions ?? ""; portionCount.textContent = portions ?? "";
fewerPortions.disabled = portions <= 1; 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) { async function activate(recipe) {
@@ -57,9 +121,8 @@ async function activate(recipe) {
method: "POST", method: "POST",
body: JSON.stringify({ recipeId: recipe.id }) body: JSON.stringify({ recipeId: recipe.id })
}); });
activeRecipeId = state.recipeId; applyState(state);
activeRecipeName = recipe.name; activeRecipeLabel = recipe.name;
portions = state.portions;
status.textContent = `${recipe.name} is now on the mirror.`; status.textContent = `${recipe.name} is now on the mirror.`;
render(); render();
} catch (error) { } catch (error) {
@@ -73,24 +136,36 @@ async function adjustPortions(delta) {
method: "POST", method: "POST",
body: JSON.stringify({ delta }) body: JSON.stringify({ delta })
}); });
portions = state.portions; applyState(state);
status.textContent = `${activeRecipeName} is now on the mirror.`; status.textContent = `${activeRecipeLabel} is now on the mirror.`;
render(); render();
} catch (error) { } catch (error) {
status.textContent = error.message; 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)); fewerPortions.addEventListener("click", () => adjustPortions(-1));
morePortions.addEventListener("click", () => adjustPortions(1)); morePortions.addEventListener("click", () => adjustPortions(1));
exit.addEventListener("click", async () => { exit.addEventListener("click", async () => {
status.textContent = "Leaving recipe mode…"; status.textContent = "Leaving recipe mode…";
try { try {
await request("/exit", { method: "POST", body: "{}" }); const state = await request("/exit", { method: "POST", body: "{}" });
activeRecipeId = null; applyState(state);
activeRecipeName = null;
portions = null;
status.textContent = "The mirror is back to its normal display."; status.textContent = "The mirror is back to its normal display.";
render(); render();
} catch (error) { } catch (error) {
@@ -102,9 +177,7 @@ search.addEventListener("input", render);
Promise.all([request("/state"), request("/recipes")]) Promise.all([request("/state"), request("/recipes")])
.then(([state, loadedRecipes]) => { .then(([state, loadedRecipes]) => {
activeRecipeId = state.recipeId; applyState(state);
activeRecipeName = state.recipe?.name || null;
portions = state.portions;
recipes = loadedRecipes; recipes = loadedRecipes;
status.textContent = `${recipes.length} recipes available.`; status.textContent = `${recipes.length} recipes available.`;
render(); render();
+12
View File
@@ -21,6 +21,18 @@
<span><strong id="portion-count"></strong> portions</span> <span><strong id="portion-count"></strong> portions</span>
<button id="more-portions" type="button" aria-label="One more portion">+</button> <button id="more-portions" type="button" aria-label="One more portion">+</button>
</div> </div>
<article id="active-recipe" class="active-recipe" hidden>
<h2 id="active-recipe-name"></h2>
<p id="active-recipe-yield"></p>
<section>
<h3>Ingredients</h3>
<ul id="active-ingredients" class="checklist"></ul>
</section>
<section>
<h3>Instructions</h3>
<ol id="active-instructions" class="checklist"></ol>
</section>
</article>
<label for="search">Find a recipe</label> <label for="search">Find a recipe</label>
<input id="search" type="search" autocomplete="off" placeholder="Search recipes"> <input id="search" type="search" autocomplete="off" placeholder="Search recipes">
<ul id="recipes" aria-live="polite"></ul> <ul id="recipes" aria-live="polite"></ul>
+24
View File
@@ -102,6 +102,30 @@ button {
.recipe strong { font-size: 1.12rem; } .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 { .recipe span {
display: -webkit-box; display: -webkit-box;
overflow: hidden; overflow: hidden;
+35
View File
@@ -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 };
+54 -7
View File
@@ -5,11 +5,15 @@ const NodeHelper = require("node_helper");
const QRCode = require("qrcode"); const QRCode = require("qrcode");
const NextcloudPublicShare = require("./lib/nextcloud-public-share"); const NextcloudPublicShare = require("./lib/nextcloud-public-share");
const { parseYield, scaleRecipe } = require("./lib/recipe-scaler"); const { parseYield, scaleRecipe } = require("./lib/recipe-scaler");
const { normalizeProgress, updateProgress } = require("./lib/recipe-progress");
module.exports = NodeHelper.create({ module.exports = NodeHelper.create({
start() { start() {
this.config = { controlPath: "/MMM-NextcloudCookbook/control" }; 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.recipeCache = { expires: 0, recipes: [] };
this.stateFile = path.join(process.cwd(), "config", "MMM-NextcloudCookbook-state.json"); this.stateFile = path.join(process.cwd(), "config", "MMM-NextcloudCookbook-state.json");
this.configureClient(); this.configureClient();
@@ -18,6 +22,10 @@ module.exports = NodeHelper.create({
}, },
socketNotificationReceived(notification, payload) { socketNotificationReceived(notification, payload) {
if (notification === "UPDATE_PROGRESS") {
this.updateProgressFromMirror(payload);
return;
}
if (notification !== "CONFIG") return; if (notification !== "CONFIG") return;
this.config = { ...this.config, ...payload }; this.config = { ...this.config, ...payload };
try { try {
@@ -82,11 +90,17 @@ module.exports = NodeHelper.create({
return response.status(400).json({ error: "recipeId is required" }); return response.status(400).json({ error: "recipeId is required" });
} }
const recipe = await this.getClient().getRecipe(recipeId); const recipe = await this.getClient().getRecipe(recipeId);
const isResumingRecipe = this.state.recipeId === recipeId
&& Number.isInteger(this.state.portions)
&& this.state.portions > 0;
this.state = { this.state = {
active: true, active: true,
recipeId, recipeId,
recipe, 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 error: null
}; };
await this.saveState(); await this.saveState();
@@ -113,9 +127,25 @@ module.exports = NodeHelper.create({
return next(error); 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) => { this.expressApp.post("/MMM-NextcloudCookbook/api/exit", async (_request, response, next) => {
try { try {
this.state = { active: false, recipeId: null, recipe: null, portions: null, error: null }; this.state = { ...this.state, active: false, error: null };
await this.saveState(); await this.saveState();
this.sendSocketNotification("STATE", this.publicState()); this.sendSocketNotification("STATE", this.publicState());
response.json(this.publicState()); response.json(this.publicState());
@@ -144,12 +174,13 @@ module.exports = NodeHelper.create({
async loadState() { async loadState() {
try { try {
const persisted = JSON.parse(await fs.readFile(this.stateFile, "utf8")); const persisted = JSON.parse(await fs.readFile(this.stateFile, "utf8"));
if (!persisted.active || !persisted.recipeId) return; if (!persisted.recipeId) return;
this.state = { this.state = {
active: true, active: Boolean(persisted.active),
recipeId: persisted.recipeId, recipeId: persisted.recipeId,
recipe: null, recipe: null,
portions: Number.isInteger(persisted.portions) ? persisted.portions : null, portions: Number.isInteger(persisted.portions) ? persisted.portions : null,
progress: normalizeProgress(persisted.progress),
error: null error: null
}; };
} catch (error) { } catch (error) {
@@ -174,7 +205,8 @@ module.exports = NodeHelper.create({
await fs.writeFile(temporary, `${JSON.stringify({ await fs.writeFile(temporary, `${JSON.stringify({
active: this.state.active, active: this.state.active,
recipeId: this.state.recipeId, recipeId: this.state.recipeId,
portions: this.state.portions portions: this.state.portions,
progress: this.state.progress
})}\n`, { })}\n`, {
mode: 0o600 mode: 0o600
}); });
@@ -190,9 +222,24 @@ module.exports = NodeHelper.create({
return { return {
active: this.state.active, active: this.state.active,
recipeId: this.state.recipeId, 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, portions: this.state.portions,
progress: this.state.progress,
error: this.state.error 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());
}
} }
}); });
+1 -1
View File
@@ -5,7 +5,7 @@
"description": "Display recipes from a Nextcloud Cookbook share on MagicMirror", "description": "Display recipes from a Nextcloud Cookbook share on MagicMirror",
"main": "node_helper.js", "main": "node_helper.js",
"scripts": { "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" "test": "npm run check && node --test"
}, },
"engines": { "engines": {
+25
View File
@@ -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/);
});