feat: annotate scaled ingredient quantities

Refs #1, #2
This commit is contained in:
2026-08-14 13:28:39 -05:00
parent de8eab9022
commit b61ca70bed
2 changed files with 52 additions and 15 deletions
+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 };
+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"
});
}); });