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
+87 -14
View File
@@ -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();