PatchHub

← patchhub/recipe-box

Servings control that scales recipe ingredient amounts

Published ✓ Safe by @devon 0 users2 votes 2 files changed (+137 −1)

Adds a Servings box with − and + buttons above each recipe's ingredients. Changing the number instantly rescales the ingredient amounts on screen, with a Reset link to go back. The saved recipe is never changed.

What it does

- On each recipe's detail page with at least one ingredient, show a scaler row directly under the "Ingredients" heading and above the ingredient list. - The row contains: a label "Servings:", a "−" button (aria-label "Fewer"), a number input (aria-label "Number of servings"), a "+" button (aria-label "More"), and a note area. - Base servings is the first number (integer or decimal) found in the recipe's servings text. The input starts at that number and steps by 1; minimum is 1. - If the recipe has no usable servings number, the label is "Batches:", the input (aria-label "Number of batches") starts at 1 and steps by 0.5, and the note reads "(amounts × N) ". - Scale factor = current value / base value. Typing a positive number updates amounts immediately. Values are rounded to 2 decimals. Invalid or non-positive input is ignored, and on blur reverts to the last valid value. - When the factor is not 1, the note shows "(recipe is for X) " (X = original servings text) followed by a "Reset" link button that restores the original value. Nothing is shown when the factor is 1. The note and the −/+ buttons are hidden when printing. - Scaling is display-only. The stored recipe is not modified, and each time a recipe is opened it starts at its original servings. - Only the amount at the start of an ingredient line is scaled, plus a bracketed amount that follows a unit (e.g. "1 1/2 cups (190 g) flour" at 4→8 servings becomes "3 cups (380 g) flour"). If the line's leading amount is immediately followed by a bracket (e.g. "1 (400 g) can tomatoes"), the bracket is left unchanged. Lines with no leading number ("Pinch of chili flakes") are unchanged. - Recognised amount formats: mixed fractions ("1 1/2"), whole+unicode fractions ("1½"), simple fractions ("1/2"), unicode fractions (½ ⅓ ⅔ ¼ ¾ ⅕–⅘ ⅙ ⅚ ⅛ ⅜ ⅝ ⅞), decimals, integers, and ranges with - or – ("2–3 cloves" scales both ends). An amount must be followed by whitespace, a closing bracket, a letter (e.g. "200g") or end of line. - Output formatting: if the original amount used a decimal point, keep decimals (2 decimal places, whole number once ≥100). Otherwise, values ≥20 round to whole numbers. Smaller values are shown as a whole number plus the nearest of 1/8, 1/4, 1/3, 3/8, 1/2, 5/8, 2/3, 3/4, 7/8 (rounding up to the next whole number when closest). A real amount never rounds down to nothing (minimum shown is 1/8). - Ingredient wording is not adjusted (e.g. "2 egg" may appear). - Styling: the row is a wrapping flex layout. The buttons are 34px square, the input is 64px wide and centered, and the note text is muted and smaller. - README gets a bullet describing the feature: "Change the servings" using −/+ or typing, batches for recipes without servings, display-only.

Safety check ✓ Safe

The changes are limited to front-end display logic, styling and a README note. There are no network requests, new dependencies, storage changes, or build/install script changes, and the code matches what was requested.

Conversation that created this patch
@devon
Add a servings control that scales the ingredient amounts.
AI
I added a servings control to every recipe page. It sits just above the ingredients list and has **−** and **+** buttons plus a box where you can type a number. When you change it, the ingredient amounts update straight away. I tested the maths on the example recipes, but I couldn't open the page in a browser here, so please click around once to check it looks right. - **How amounts look:** for example, going from 4 to 8 servings of the pancakes turns "1 1/2 cups (190 g) flour" into "3 cups (380 g) flour". - Fractions stay as kitchen-friendly fractions like 1/3 or 3/4. - Decimals like "1.5 litres" stay as decimals. - Large gram amounts are rounded to whole numbers. - Ranges like "2–3 cloves" scale too. - **What's left alone:** lines with no number, like "Pinch of chili flakes" or "Salt and pepper", don't change. Neither does a can size in brackets, like the "400 g" in "1 (400 g) can tomatoes". - **Your saved recipe isn't changed:** the new amounts are only shown on screen. When you've changed the number, a note shows the original servings and a **Reset** link puts it back. It also starts from the original again each time you open a recipe. - **Recipes with no servings number:** the control lets you change the number of batches instead. - **Printing:** the page prints with the amounts you've chosen. Words aren't adjusted for the new amounts, so you might see things like "2 egg" or "1/2 egg". I also added a short note about this feature to the README.
Code changes (for the curious, 2 files) · raw
README.md +4 −0
@@ -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?
index.html +133 −1
@@ -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, [