diff --git a/README.md b/README.md index a0b1f67..8452f0b 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,10 @@ That's it. descriptions, tags, ingredients and steps. - **Filter by tag**: click a tag (like "dinner" or "vegetarian") above the list. - **While cooking**: tick off ingredients as you gather them. +- **Change the servings**: on a recipe, use the **−** and **+** buttons (or type a + number) next to "Servings" and the ingredient amounts update to match. Recipes + without a servings number let you change the number of batches instead. + This only changes what you see; the saved recipe stays as you wrote it. - **Edit, delete or print** a recipe using the buttons at the top of the recipe. ## Where are my recipes saved? diff --git a/index.html b/index.html index 93857b2..800b560 100644 --- a/index.html +++ b/index.html @@ -123,6 +123,15 @@ ul.ingredients li { padding: 4px 0; display: flex; gap: 8px; align-items: flex-start; } ul.ingredients input { margin-top: 5px; accent-color: var(--accent); } ul.ingredients li.done span { text-decoration: line-through; color: var(--muted); } + .scaler { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin: 0 0 12px; font-size: 0.95rem; } + .scaler label { font-weight: 600; margin-right: 2px; } + .scaler button.step { width: 34px; height: 34px; padding: 0; font-size: 1.1rem; line-height: 1; } + .scaler input { + width: 64px; padding: 6px 8px; text-align: center; font: inherit; + border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg); + } + .scaler .note { color: var(--muted); font-size: 0.88rem; } + .scaler .note button.link { font-size: 0.88rem; } ol.steps { padding-left: 22px; margin: 0; } ol.steps li { margin-bottom: 10px; } .actions { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; } @@ -323,6 +332,77 @@ return Object.keys(set).sort(function (a, b) { return a.localeCompare(b); }); } + // ---------- Scaling ingredient amounts ---------- + var VULGAR = { "½": 1/2, "⅓": 1/3, "⅔": 2/3, "¼": 1/4, "¾": 3/4, "⅕": 1/5, "⅖": 2/5, "⅗": 3/5, "⅘": 4/5, + "⅙": 1/6, "⅚": 5/6, "⅛": 1/8, "⅜": 3/8, "⅝": 5/8, "⅞": 7/8 }; + var VCHARS = "½⅓⅔¼¾⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞"; + // One amount: "1 1/2", "1½", "1/2", "1.5", "2" or "½" + var NUM = "(?:\\d+\\s+\\d+\\/\\d+|\\d+\\s*[" + VCHARS + "]|\\d+\\/\\d+|\\d+(?:\\.\\d+)?|[" + VCHARS + "])"; + // An amount or a range like "2-3", followed by a space, a letter (e.g. "200g") or the end + var AMOUNT = "(" + NUM + ")(?:(\\s*[-–]\\s*)(" + NUM + "))?(?=[\\s)a-zA-Z]|$)"; + var LEADING_RE = new RegExp("^(\\s*)" + AMOUNT); + var PAREN_RE = new RegExp("\\((\\s*)" + AMOUNT, "g"); + var NICE = [0, 1/8, 1/4, 1/3, 3/8, 1/2, 5/8, 2/3, 3/4, 7/8, 1]; + var NICE_TEXT = ["", "1/8", "1/4", "1/3", "3/8", "1/2", "5/8", "2/3", "3/4", "7/8", ""]; + + function parseAmount(str) { + str = str.trim(); + var m; + if ((m = str.match(/^(\d+)\s+(\d+)\/(\d+)$/))) return +m[3] ? +m[1] + m[2] / m[3] : NaN; + if ((m = str.match(new RegExp("^(\\d+)\\s*([" + VCHARS + "])$")))) return +m[1] + VULGAR[m[2]]; + if ((m = str.match(/^(\d+)\/(\d+)$/))) return +m[2] ? m[1] / m[2] : NaN; + if (VULGAR[str] !== undefined) return VULGAR[str]; + return parseFloat(str); + } + + function formatAmount(value, original) { + if (/\./.test(original)) { + // The recipe used decimals (e.g. "1.5 litres"), so keep decimals + var dec = value >= 100 ? Math.round(value) : Math.round(value * 100) / 100; + return String(dec); + } + if (value >= 20) return String(Math.round(value)); + var whole = Math.floor(value); + var rest = value - whole; + var best = 0; + for (var i = 1; i < NICE.length; i++) { + if (Math.abs(NICE[i] - rest) < Math.abs(NICE[best] - rest)) best = i; + } + if (best === NICE.length - 1) { whole += 1; best = 0; } + if (whole === 0 && best === 0) return "1/8"; // never round a real amount down to nothing + if (best === 0) return String(whole); + return whole ? whole + " " + NICE_TEXT[best] : NICE_TEXT[best]; + } + + function scaleMatch(factor, lead, a, sep, b) { + var out = lead + formatAmount(parseAmount(a) * factor, a); + if (b) out += sep + formatAmount(parseAmount(b) * factor, b); + return out; + } + + // Scales the amount at the start of an ingredient line, plus any amount in + // brackets that follows a unit, e.g. "1 1/2 cups (190 g) flour". + // Package sizes such as "1 (400 g) can tomatoes" are left alone. + function scaleIngredient(text, factor) { + if (factor === 1) return text; + var m = text.match(LEADING_RE); + if (!m) return text; + var head = scaleMatch(factor, m[1], m[2], m[3], m[4]); + var rest = text.slice(m[0].length); + if (!/^\s*\(/.test(rest)) { + rest = rest.replace(PAREN_RE, function (all, lead, a, sep, b) { + return "(" + scaleMatch(factor, lead, a, sep, b); + }); + } + return head + rest; + } + + function baseServings(r) { + var m = String(r.servings || "").match(/\d+(?:\.\d+)?/); + var n = m ? parseFloat(m[0]) : 0; + return n > 0 ? n : 0; + } + // ---------- State ---------- var state = { view: "list", id: null, query: "", tag: null }; var app = document.getElementById("app"); @@ -451,13 +531,64 @@ if (r.servings) meta.push(el("span", { text: "🍽 Serves " + r.servings })); var ing = el("ul", { class: "ingredients" }); + var labels = []; (r.ingredients || []).forEach(function (item) { var box = el("input", { type: "checkbox", "aria-label": "Mark as gathered" }); - var li = el("li", null, [box, el("span", { text: item })]); + var label = el("span", { text: item }); + labels.push(label); + var li = el("li", null, [box, label]); box.addEventListener("change", function () { li.classList.toggle("done", box.checked); }); ing.appendChild(li); }); + // Servings control: scales the ingredient amounts. + // If the recipe has no servings number, it scales by batches instead. + var base = baseServings(r); + var byBatch = !base; + if (byBatch) base = 1; + var stepSize = byBatch ? 0.5 : 1; + var current = base; + var qtyInput = el("input", { type: "number", min: String(stepSize), step: String(stepSize), + inputmode: "decimal", "aria-label": byBatch ? "Number of batches" : "Number of servings" }); + qtyInput.value = String(current); + var note = el("span", { class: "note" }); + + function applyScale() { + var factor = current / base; + labels.forEach(function (label, i) { label.textContent = scaleIngredient(r.ingredients[i], factor); }); + note.innerHTML = ""; + if (factor !== 1) { + note.appendChild(document.createTextNode( + byBatch ? "(amounts × " + current + ") " : "(recipe is for " + r.servings + ") ")); + note.appendChild(el("button", { class: "link no-print", text: "Reset", onclick: function () { setQty(base); } })); + } + } + function setQty(n) { + n = Math.round(n * 100) / 100; + if (!(n > 0)) return; + current = n; + qtyInput.value = String(n); + applyScale(); + } + qtyInput.addEventListener("input", function () { + var n = parseFloat(qtyInput.value); + if (n > 0) { current = Math.round(n * 100) / 100; applyScale(); } + }); + qtyInput.addEventListener("change", function () { setQty(parseFloat(qtyInput.value) > 0 ? parseFloat(qtyInput.value) : current); }); + + var scaler = el("div", { class: "scaler" }, [ + el("label", { text: byBatch ? "Batches:" : "Servings:" }), + el("button", { class: "step no-print", type: "button", text: "−", "aria-label": "Fewer", onclick: function () { + var next = Math.ceil(current / stepSize - 1e-9) * stepSize - stepSize; + if (next >= stepSize) setQty(next); + } }), + qtyInput, + el("button", { class: "step no-print", type: "button", text: "+", "aria-label": "More", onclick: function () { + setQty(Math.floor(current / stepSize + 1e-9) * stepSize + stepSize); + } }), + note + ]); + var steps = el("ol", { class: "steps" }); (r.steps || []).forEach(function (s) { steps.appendChild(el("li", { text: s })); }); @@ -470,6 +601,7 @@ el("div", { class: "columns" }, [ el("div", null, [ el("h3", { text: "Ingredients" }), + (r.ingredients && r.ingredients.length) ? scaler : null, (r.ingredients && r.ingredients.length) ? ing : el("p", { class: "desc", text: "No ingredients listed." }) ]), el("div", null, [