5 Commits
Author SHA1 Message Date
aaron.axvig a8fbcf4104 fix: preserve recipe progress view state
Refs #1, #5
2026-08-14 21:02:21 -05:00
aaron.axvig 22daf703bb feat: add recipe theme and resume controls
Fixes #4, #7
2026-08-14 13:51:00 -05:00
aaron.axvig 803d722836 chore: ignore local environment files 2026-08-14 13:29:05 -05:00
aaron.axvig 6f28f83f4a feat: synchronize recipe completion controls
Fixes #5\nRefs #1, #2
2026-08-14 13:28:57 -05:00
aaron.axvig b61ca70bed feat: annotate scaled ingredient quantities
Refs #1, #2
2026-08-14 13:28:39 -05:00
13 changed files with 596 additions and 52 deletions
+1
View File
@@ -1,3 +1,4 @@
node_modules/ node_modules/
coverage/ coverage/
*.log *.log
.env
+69
View File
@@ -15,6 +15,15 @@
text-align: left; text-align: left;
} }
.MMM-NextcloudCookbook .ncc-active.ncc-theme-light {
color: #27231b;
background: #f8f6ef;
}
.MMM-NextcloudCookbook .ncc-theme-light .ncc-recipe h1 { color: #302817; }
.MMM-NextcloudCookbook .ncc-theme-light .ncc-meta dt,
.MMM-NextcloudCookbook .ncc-theme-light .ncc-columns h2 { color: #765900; }
.MMM-NextcloudCookbook .ncc-qr { .MMM-NextcloudCookbook .ncc-qr {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -37,6 +46,28 @@
top: 18px; top: 18px;
} }
.MMM-NextcloudCookbook .ncc-theme-toggle {
padding: 10px 14px;
border: 0;
border-radius: 6px;
color: #27231b;
background: #fff;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.MMM-NextcloudCookbook .ncc-active .ncc-theme-toggle {
position: absolute;
top: 190px;
right: 18px;
}
.MMM-NextcloudCookbook .ncc-theme-light .ncc-theme-toggle {
color: #f8f6ef;
background: #302817;
}
.MMM-NextcloudCookbook .ncc-qr img { .MMM-NextcloudCookbook .ncc-qr img {
width: 164px; width: 164px;
height: 164px; height: 164px;
@@ -117,6 +148,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;
+93 -11
View File
@@ -6,6 +6,7 @@ Module.register("MMM-NextcloudCookbook", {
controlUrl: "", controlUrl: "",
animationSpeed: 400, animationSpeed: 400,
layout: "stacked", layout: "stacked",
theme: "dark",
shareUrl: "", shareUrl: "",
sharePassword: "" sharePassword: ""
}, },
@@ -27,9 +28,17 @@ Module.register("MMM-NextcloudCookbook", {
socketNotificationReceived(notification, payload) { socketNotificationReceived(notification, payload) {
if (notification !== "STATE") return; if (notification !== "STATE") return;
const previous = this.state;
this.state = payload; this.state = payload;
this.setRecipeMode(Boolean(payload.active)); this.setRecipeMode(Boolean(payload.active));
this.updateDom(this.config.animationSpeed); const requiresDomUpdate = previous.active !== payload.active
|| previous.recipeId !== payload.recipeId
|| previous.portions !== payload.portions
|| previous.theme !== payload.theme
|| previous.error !== payload.error
|| previous.recipe?.recipeYield !== payload.recipe?.recipeYield;
if (requiresDomUpdate) this.updateDom(this.config.animationSpeed);
else this.updateProgressDom();
}, },
notificationReceived(notification) { notificationReceived(notification) {
@@ -85,11 +94,27 @@ Module.register("MMM-NextcloudCookbook", {
} }
}, },
updateProgressDom() {
const root = document.getElementById(this.identifier)?.querySelector(".ncc-active");
if (!root) return;
root.querySelectorAll("[data-ncc-progress-kind]").forEach((item) => {
const kind = item.dataset.nccProgressKind;
const index = Number(item.dataset.nccProgressIndex);
const completed = kind === "ingredient"
? this.state.progress?.completedIngredients?.includes(index)
: this.state.progress?.completedSteps?.includes(index);
item.classList.toggle("ncc-completed", completed);
const checkbox = item.querySelector("input[type=checkbox]");
if (checkbox) {
checkbox.checked = completed;
checkbox.disabled = false;
}
});
},
getDom() { getDom() {
const root = document.createElement("section"); const root = document.createElement("section");
root.className = this.state.active root.className = `ncc-root ${this.state.active ? "ncc-active" : "ncc-idle"} ncc-theme-${this.state.theme || this.config.theme}`;
? "ncc-root ncc-active"
: "ncc-root ncc-idle";
const qr = document.createElement("a"); const qr = document.createElement("a");
qr.className = "ncc-qr"; qr.className = "ncc-qr";
@@ -105,6 +130,14 @@ Module.register("MMM-NextcloudCookbook", {
qr.append(qrLabel, qrImage); qr.append(qrLabel, qrImage);
root.appendChild(qr); root.appendChild(qr);
const themeToggle = document.createElement("button");
themeToggle.type = "button";
themeToggle.className = "ncc-theme-toggle";
const nextTheme = this.state.theme === "light" ? "dark" : "light";
themeToggle.textContent = `Use ${nextTheme} mode`;
themeToggle.addEventListener("click", () => this.sendSocketNotification("SET_THEME", nextTheme));
root.appendChild(themeToggle);
if (!this.state.active) return root; if (!this.state.active) return root;
if (this.state.error) { if (this.state.error) {
@@ -123,11 +156,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";
@@ -136,8 +169,11 @@ Module.register("MMM-NextcloudCookbook", {
title.textContent = recipe.name || "Untitled recipe"; title.textContent = recipe.name || "Untitled recipe";
header.appendChild(title); header.appendChild(title);
const yieldValue = recipe.originalRecipeYield && recipe.originalRecipeYield !== recipe.recipeYield
? `${recipe.recipeYield} (originally ${recipe.originalRecipeYield})`
: recipe.recipeYield;
const metadata = [ const metadata = [
["Yield", recipe.recipeYield], ["Yield", yieldValue],
["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 +204,36 @@ 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; item.dataset.nccProgressKind = "ingredient";
item.dataset.nccProgressIndex = index;
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 +243,28 @@ 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; item.dataset.nccProgressKind = "step";
item.dataset.nccProgressIndex = index;
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);
+19 -2
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
@@ -17,7 +20,8 @@ link; it should look like `https://cloud.example.test/s/SHARE_TOKEN`.
config: { config: {
shareUrl: "https://cloud.example.test/s/SHARE_TOKEN", shareUrl: "https://cloud.example.test/s/SHARE_TOKEN",
controlUrl: "http://mirror.example.test:8080/MMM-NextcloudCookbook/control", controlUrl: "http://mirror.example.test:8080/MMM-NextcloudCookbook/control",
layout: "stacked" layout: "stacked",
theme: "light"
} }
} }
``` ```
@@ -30,6 +34,19 @@ 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.
`theme` defaults to `"dark"`. Set it to `"light"` for a light starting theme;
the controller and recipe overlay also offer a toggle, and the chosen theme is
saved locally. After exiting recipe mode, use **Resume last recipe** in the
controller to restore its portions and completion marks.
Ingredients and instructions can be marked complete from either the phone
controller or a touchscreen on the mirror; both views remain synchronized.
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
+118 -14
View File
@@ -3,14 +3,27 @@ const status = document.querySelector("#status");
const list = document.querySelector("#recipes"); const list = document.querySelector("#recipes");
const search = document.querySelector("#search"); const search = document.querySelector("#search");
const exit = document.querySelector("#exit"); const exit = document.querySelector("#exit");
const resume = document.querySelector("#resume");
const themeToggle = document.querySelector("#theme-toggle");
const portionControls = document.querySelector("#portions"); 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;
let canResume = false;
let lastRecipeName = null;
let theme = "dark";
async function request(path, options) { async function request(path, options) {
const response = await fetch(`${apiBase}${path}`, { const response = await fetch(`${apiBase}${path}`, {
@@ -45,9 +58,71 @@ 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;
resume.hidden = !canResume;
resume.textContent = lastRecipeName ? `Resume ${lastRecipeName}` : "Resume last recipe";
themeToggle.textContent = theme === "light" ? "Use dark mode" : "Use light mode";
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} (originally ${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: [] };
canResume = Boolean(state.canResume);
lastRecipeName = state.lastRecipeName || null;
theme = state.theme === "light" ? "light" : "dark";
} }
async function activate(recipe) { async function activate(recipe) {
@@ -57,9 +132,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 +147,56 @@ 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);
} 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));
resume.addEventListener("click", async () => {
try {
applyState(await request("/resume", { method: "POST", body: "{}" }));
status.textContent = `${activeRecipeLabel} is back on the mirror.`;
render();
} catch (error) {
status.textContent = error.message;
}
});
themeToggle.addEventListener("click", async () => {
try {
applyState(await request("/theme", {
method: "POST", body: JSON.stringify({ theme: theme === "light" ? "dark" : "light" })
}));
render();
} catch (error) {
status.textContent = error.message;
}
});
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 +208,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();
+16
View File
@@ -13,7 +13,11 @@
<p class="eyebrow">Kitchen Mirror</p> <p class="eyebrow">Kitchen Mirror</p>
<h1>Recipes</h1> <h1>Recipes</h1>
</div> </div>
<div class="header-actions">
<button id="theme-toggle" class="secondary" type="button"></button>
<button id="resume" class="secondary" type="button" hidden>Resume last recipe</button>
<button id="exit" class="secondary" type="button">Exit recipe mode</button> <button id="exit" class="secondary" type="button">Exit recipe mode</button>
</div>
</header> </header>
<p id="status" role="status">Loading recipes…</p> <p id="status" role="status">Loading recipes…</p>
<div id="portions" class="portions" hidden> <div id="portions" class="portions" hidden>
@@ -21,6 +25,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>
+42
View File
@@ -23,6 +23,8 @@ header {
justify-content: space-between; justify-content: space-between;
} }
.header-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; }
h1 { margin: 0; font-size: 2.3rem; } h1 { margin: 0; font-size: 2.3rem; }
.eyebrow { .eyebrow {
@@ -102,6 +104,37 @@ 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; margin: 0; font-weight: normal; }
.checklist input {
width: 1.2rem;
min-width: 1.2rem;
height: 1.2rem;
min-height: 1.2rem;
margin: .18rem 0 0;
padding: 0;
}
.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;
@@ -109,3 +142,12 @@ button {
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
} }
@media (max-width: 560px) {
main { padding: 18px 14px 44px; }
header { align-items: flex-start; flex-direction: column; }
.header-actions { width: 100%; justify-content: stretch; }
.header-actions button { flex: 1 1 auto; }
.active-recipe { margin: 1rem 0; padding: .85rem; }
.checklist { gap: .55rem; padding-left: 1.2rem; }
}
+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 };
+34 -14
View File
@@ -30,33 +30,53 @@ function formatQuantity(value) {
)); ));
if (Math.abs(fraction - remainder) <= 0.02) { if (Math.abs(fraction - remainder) <= 0.02) {
const adjustedWhole = fraction === 1 ? whole + 1 : whole; const adjustedWhole = fraction === 1 ? whole + 1 : whole;
return `${adjustedWhole || ""}${adjustedWhole && symbol ? " " : ""}${symbol}` || "0"; return {
value: `${adjustedWhole || ""}${adjustedWhole && symbol ? " " : ""}${symbol}` || "0",
approximate: false
};
} }
return Number(value.toFixed(2)).toString(); return { value: Number(value.toFixed(2)).toString(), approximate: true };
}
function scaleIngredientWithStatus(ingredient, factor) {
if (typeof ingredient !== "string" || factor === 1) {
return { value: ingredient, status: "unchanged" };
}
const match = ingredient.match(/^(\s*)(?:(\d+)\s+)?(\d+\/\d+|\d+(?:\.\d+)?|[¼½¾⅓⅔⅛⅜⅝⅞])(?=\s|[a-zA-Z])/);
if (!match) {
const hasLeadingQuantity = /^\s*(?:\d|[¼½¾⅓⅔⅛⅜⅝⅞])/.test(ingredient);
return { value: ingredient, status: hasLeadingQuantity ? "unscalable" : "unquantified" };
}
const whole = Number(match[2] || 0);
const quantity = parseQuantity(match[3]);
if (!Number.isFinite(quantity)) return { value: ingredient, status: "unscalable" };
const formatted = formatQuantity((whole + quantity) * factor);
const replacement = `${match[1]}${formatted.value}`;
return {
value: replacement + ingredient.slice(match[0].length),
status: formatted.approximate ? "approximate" : "scaled"
};
} }
function scaleIngredient(ingredient, factor) { function scaleIngredient(ingredient, factor) {
if (typeof ingredient !== "string" || factor === 1) return ingredient; return scaleIngredientWithStatus(ingredient, factor).value;
const match = ingredient.match(/^(\s*)(?:(\d+)\s+)?(\d+\/\d+|\d+(?:\.\d+)?|[¼½¾⅓⅔⅛⅜⅝⅞])(?=\s|[a-zA-Z])/);
if (!match) return ingredient;
const whole = Number(match[2] || 0);
const quantity = parseQuantity(match[3]);
if (!Number.isFinite(quantity)) return ingredient;
const replacement = `${match[1]}${formatQuantity((whole + quantity) * factor)}`;
return replacement + ingredient.slice(match[0].length);
} }
function scaleRecipe(recipe, portions) { function scaleRecipe(recipe, portions) {
const basePortions = parseYield(recipe?.recipeYield); const basePortions = parseYield(recipe?.recipeYield);
if (!basePortions || !Number.isInteger(portions) || portions < 1) return recipe; if (!basePortions || !Number.isInteger(portions) || portions < 1) return recipe;
const suffix = String(recipe.recipeYield).replace(/^\s*\d+(?:\.\d+)?\s*/, ""); const suffix = String(recipe.recipeYield).replace(/^\s*\d+(?:\.\d+)?\s*/, "");
const factor = portions / basePortions;
const ingredients = (recipe.recipeIngredient || []).map((ingredient) => (
scaleIngredientWithStatus(ingredient, factor)
));
return { return {
...recipe, ...recipe,
recipeYield: suffix ? `${portions} ${suffix}` : portions, recipeYield: suffix ? `${portions} ${suffix}` : portions,
recipeIngredient: (recipe.recipeIngredient || []).map((ingredient) => ( originalRecipeYield: recipe.recipeYield,
scaleIngredient(ingredient, portions / basePortions) recipeIngredient: ingredients.map(({ value }) => value),
)) recipeIngredientStatus: ingredients.map(({ status }) => status)
}; };
} }
module.exports = { parseYield, scaleIngredient, scaleRecipe }; module.exports = { parseYield, scaleIngredient, scaleIngredientWithStatus, scaleRecipe };
+124 -8
View File
@@ -5,11 +5,20 @@ 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");
function normalizeTheme(theme) {
return theme === "light" ? "light" : "dark";
}
module.exports = NodeHelper.create({ module.exports = NodeHelper.create({
start() { start() {
this.config = { controlPath: "/MMM-NextcloudCookbook/control" }; this.config = { controlPath: "/MMM-NextcloudCookbook/control", theme: "dark" };
this.state = { active: false, recipeId: null, recipe: null, portions: null, error: null }; this.state = {
active: false, recipeId: null, recipe: null, portions: null,
recipeName: null, progress: normalizeProgress(), theme: "dark", error: null
};
this.hasPersistedTheme = false;
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 +27,14 @@ module.exports = NodeHelper.create({
}, },
socketNotificationReceived(notification, payload) { socketNotificationReceived(notification, payload) {
if (notification === "UPDATE_PROGRESS") {
this.updateProgressFromMirror(payload);
return;
}
if (notification === "SET_THEME") {
this.updateTheme(payload);
return;
}
if (notification !== "CONFIG") return; if (notification !== "CONFIG") return;
this.config = { ...this.config, ...payload }; this.config = { ...this.config, ...payload };
try { try {
@@ -27,6 +44,7 @@ module.exports = NodeHelper.create({
return; return;
} }
this.stateReady.then(async () => { this.stateReady.then(async () => {
if (!this.hasPersistedTheme) this.state.theme = normalizeTheme(this.config.theme);
this.sendSocketNotification("STATE", this.publicState()); this.sendSocketNotification("STATE", this.publicState());
await this.restoreRecipe(); await this.restoreRecipe();
}).catch((error) => this.setError(error)); }).catch((error) => this.setError(error));
@@ -82,11 +100,18 @@ 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)), recipeName: recipe.name || "Untitled recipe",
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();
@@ -96,6 +121,43 @@ module.exports = NodeHelper.create({
return next(error); return next(error);
} }
}); });
this.expressApp.post("/MMM-NextcloudCookbook/api/resume", async (_request, response, next) => {
try {
if (this.state.active) return response.json(this.publicState());
if (!this.state.recipeId) return response.status(409).json({ error: "No recent recipe to resume" });
const recipe = await this.getClient().getRecipe(this.state.recipeId);
this.state = {
...this.state,
active: true,
recipe,
recipeName: recipe.name || "Untitled recipe",
portions: Number.isInteger(this.state.portions) && this.state.portions > 0
? this.state.portions
: Math.max(1, Math.round(parseYield(recipe.recipeYield) || 1)),
progress: normalizeProgress(this.state.progress),
error: null
};
await this.saveState();
this.sendSocketNotification("STATE", this.publicState());
return response.json(this.publicState());
} catch (error) {
return next(error);
}
});
this.expressApp.post("/MMM-NextcloudCookbook/api/theme", async (request, response, next) => {
try {
const theme = request.body?.theme;
if (!["light", "dark"].includes(theme)) {
return response.status(400).json({ error: "theme must be light or dark" });
}
this.state.theme = theme;
await this.saveState();
this.sendSocketNotification("STATE", this.publicState());
return response.json(this.publicState());
} catch (error) {
return next(error);
}
});
this.expressApp.post("/MMM-NextcloudCookbook/api/portions", async (request, response, next) => { this.expressApp.post("/MMM-NextcloudCookbook/api/portions", async (request, response, next) => {
try { try {
const delta = request.body?.delta; const delta = request.body?.delta;
@@ -113,9 +175,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 +222,17 @@ 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; this.hasPersistedTheme = typeof persisted.theme === "string";
this.state.theme = normalizeTheme(persisted.theme);
if (!persisted.recipeId) return;
this.state = { this.state = {
active: true, active: Boolean(persisted.active),
recipeId: persisted.recipeId, recipeId: persisted.recipeId,
recipe: null, recipe: null,
recipeName: typeof persisted.recipeName === "string" ? persisted.recipeName : null,
portions: Number.isInteger(persisted.portions) ? persisted.portions : null, portions: Number.isInteger(persisted.portions) ? persisted.portions : null,
progress: normalizeProgress(persisted.progress),
theme: this.state.theme,
error: null error: null
}; };
} catch (error) { } catch (error) {
@@ -174,7 +257,10 @@ 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 recipeName: this.state.recipeName,
portions: this.state.portions,
progress: this.state.progress,
theme: this.state.theme
})}\n`, { })}\n`, {
mode: 0o600 mode: 0o600
}); });
@@ -190,9 +276,39 @@ 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,
theme: this.state.theme,
canResume: !this.state.active && Boolean(this.state.recipeId),
lastRecipeName: !this.state.active ? this.state.recipeName : null,
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());
}
},
async updateTheme(theme) {
try {
if (!["light", "dark"].includes(theme)) throw new Error("theme must be light or dark");
this.state.theme = theme;
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/);
});
+18 -1
View File
@@ -1,6 +1,6 @@
const test = require("node:test"); const test = require("node:test");
const assert = require("node:assert/strict"); const assert = require("node:assert/strict");
const { parseYield, scaleIngredient, scaleRecipe } = require("../lib/recipe-scaler"); const { parseYield, scaleIngredient, scaleIngredientWithStatus, scaleRecipe } = require("../lib/recipe-scaler");
test("extracts the number of portions from a recipe yield", () => { test("extracts the number of portions from a recipe yield", () => {
assert.equal(parseYield("6 servings"), 6); assert.equal(parseYield("6 servings"), 6);
@@ -25,4 +25,21 @@ test("returns a scaled copy of a recipe", () => {
assert.equal(scaled.recipeYield, "9 servings"); assert.equal(scaled.recipeYield, "9 servings");
assert.deepEqual(scaled.recipeIngredient, ["3 cups oats", "Salt to taste"]); assert.deepEqual(scaled.recipeIngredient, ["3 cups oats", "Salt to taste"]);
assert.equal(original.recipeYield, "6 servings"); assert.equal(original.recipeYield, "6 servings");
assert.equal(scaled.originalRecipeYield, "6 servings");
assert.deepEqual(scaled.recipeIngredientStatus, ["scaled", "unquantified"]);
});
test("reports ingredient scaling confidence", () => {
assert.deepEqual(scaleIngredientWithStatus("2 cups oats", 1.5), {
value: "3 cups oats", status: "scaled"
});
assert.deepEqual(scaleIngredientWithStatus("Salt to taste", 2), {
value: "Salt to taste", status: "unquantified"
});
assert.deepEqual(scaleIngredientWithStatus("1-2 tbsp oil", 2), {
value: "1-2 tbsp oil", status: "unscalable"
});
assert.deepEqual(scaleIngredientWithStatus("1 cup milk", 0.7), {
value: "0.7 cup milk", status: "approximate"
});
}); });