@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
+87
-14
@@ -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();
|
||||
|
||||
@@ -21,6 +21,18 @@
|
||||
<span><strong id="portion-count"></strong> portions</span>
|
||||
<button id="more-portions" type="button" aria-label="One more portion">+</button>
|
||||
</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>
|
||||
<input id="search" type="search" autocomplete="off" placeholder="Search recipes">
|
||||
<ul id="recipes" aria-live="polite"></ul>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user