Servings control that scales recipe ingredient amounts
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, [ |