Skip to content

Commit de6cfdc

Browse files
committed
feat: scale cookware quantities
1 parent e9c2761 commit de6cfdc

5 files changed

Lines changed: 184 additions & 1 deletion

File tree

docs/examples-scaling-recipes.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const scaledRecipe = recipe.scaleBy(2)
3030

3131
In the above example, will be multiplied by 2:
3232
- All the ingredients (including alternative units and alternative ingredients) with scalable numerical quantities
33+
- [Cookware quantities](/guide-extensions.html#cookware-quantities) (integer quantities are rounded up, with a minimum of 1)
3334
- The scaling metadata and `servings` value
3435
- [Arbitrary scalable quantities](/guide-extensions.html#arbitrary-scalable-quantities)
3536

@@ -46,5 +47,6 @@ const scaledRecipe = recipe.scaleTo(4)
4647

4748
In the above example, will be adjusted by a factor of 4/2:
4849
- All the ingredients (including alternative units and alternative ingredients) with scalable numerical quantities have their quantities adjusted by a factor of 4/2 in this case
50+
- [Cookware quantities](/guide-extensions.html#cookware-quantities) (integer quantities are rounded up, with a minimum of 1)
4951
- The scaling metadata and `servings` value
5052
- [Arbitrary scalable quantities](/guide-extensions.html#arbitrary-scalable-quantities)

docs/guide-extensions.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,11 @@ Example: .cook string `Mix @wheat flour{100%g} with additional @&wheat flour|flo
206206

207207
- Cookware can also be quantified (without any unit, e.g. `#bowls{2}`)
208208
- Quantities will be added similarly as ingredients if cookware is referenced, e.g. `#&bowls{2}`
209+
- Cookware quantities are scaled by [`scaleBy`](/api/classes/Recipe#scaleBy) and [`scaleTo`](/api/classes/Recipe#scaleTo), with two special rules:
210+
- **Integer quantities** are rounded up (ceiling) after scaling, with a minimum of `1`. For example, `#bowl{1}` scaled by `0.5` stays `1` (not `0.5`), and scaled by `1.5` becomes `2`.
211+
- **Non-integer quantities** are scaled as-is without rounding. For example, `#sticks{2.5}` scaled by `1.5` becomes `3.75`.
212+
- **Range quantities** (e.g. `#pans{1-2}`) follow the same rules — if both bounds are integers, each is individually rounded up after scaling.
213+
- Cookware without a quantity (e.g. `#oven`) is never affected by scaling.
209214

210215
## Arbitrary scalable quantities
211216

src/classes/recipe.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ import type {
2121
IngredientQuantityAndGroup,
2222
ArbitraryScalable,
2323
FixedNumericValue,
24+
FixedValue,
25+
Range,
26+
DecimalValue,
27+
FractionValue,
2428
StepItem,
2529
GetIngredientQuantitiesOptions,
2630
RawQuantityGroup,
@@ -56,7 +60,11 @@ import {
5660
parseMarkdownSegments,
5761
} from "../utils/parser_helpers";
5862
import { addEquivalentsAndSimplify } from "../quantities/alternatives";
59-
import { multiplyQuantityValue, getAverageValue } from "../quantities/numeric";
63+
import {
64+
multiplyQuantityValue,
65+
getAverageValue,
66+
getNumericValue,
67+
} from "../quantities/numeric";
6068
import {
6169
toPlainUnit,
6270
toExtendedUnit,
@@ -1801,6 +1809,51 @@ export class Recipe {
18011809
}
18021810
}
18031811

1812+
function scaleCookwareQuantity(
1813+
quantity: FixedValue | Range,
1814+
factor: number | Big,
1815+
): FixedValue | Range {
1816+
// Text quantities cannot be scaled; return as-is
1817+
if (quantity.type === "fixed" && quantity.value.type === "text") {
1818+
return quantity;
1819+
}
1820+
1821+
const isInteger = (q: FixedValue | Range): boolean => {
1822+
if (q.type === "fixed") {
1823+
return Number.isInteger(
1824+
getNumericValue(q.value as DecimalValue | FractionValue),
1825+
);
1826+
}
1827+
return (
1828+
Number.isInteger(getNumericValue(q.min)) &&
1829+
Number.isInteger(getNumericValue(q.max))
1830+
);
1831+
};
1832+
1833+
const scaled = multiplyQuantityValue(quantity, factor);
1834+
1835+
if (!isInteger(quantity)) return scaled;
1836+
1837+
if (scaled.type === "fixed") {
1838+
const n = getNumericValue(scaled.value as DecimalValue | FractionValue);
1839+
return {
1840+
type: "fixed",
1841+
value: { type: "decimal", decimal: Math.max(1, Math.ceil(n)) },
1842+
};
1843+
}
1844+
return {
1845+
type: "range",
1846+
min: {
1847+
type: "decimal",
1848+
decimal: Math.max(1, Math.ceil(getNumericValue(scaled.min))),
1849+
},
1850+
max: {
1851+
type: "decimal",
1852+
decimal: Math.max(1, Math.ceil(getNumericValue(scaled.max))),
1853+
},
1854+
};
1855+
}
1856+
18041857
// Scale IngredientItems
18051858
for (const section of newRecipe.sections) {
18061859
for (const step of section.content.filter(
@@ -1814,6 +1867,28 @@ export class Recipe {
18141867
}
18151868
}
18161869

1870+
// Scale CookwareItem quantities in steps
1871+
for (const section of newRecipe.sections) {
1872+
for (const step of section.content.filter(
1873+
(item) => item.type === "step",
1874+
)) {
1875+
for (const item of step.items.filter(
1876+
(item) => item.type === "cookware",
1877+
)) {
1878+
if (item.quantity) {
1879+
item.quantity = scaleCookwareQuantity(item.quantity, factor);
1880+
}
1881+
}
1882+
}
1883+
}
1884+
1885+
// Scale Recipe.cookware[] quantities
1886+
for (const cookware of newRecipe.cookware) {
1887+
if (cookware.quantity) {
1888+
cookware.quantity = scaleCookwareQuantity(cookware.quantity, factor);
1889+
}
1890+
}
1891+
18171892
// Scale Choices
18181893
for (const subgroups of newRecipe.choices.ingredientGroups.values()) {
18191894
for (const subgroup of subgroups) {

test/fixtures/recipes.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,12 @@ servings: 1
128128
---
129129
Mix @milk{200%ml}|almond milk{100%ml}[vegan version]|soy milk{150%ml}[another vegan option]`;
130130

131+
export const recipeCookwareScaling = `
132+
#bowl{1}
133+
#sticks{2.5}
134+
#oven
135+
`;
136+
131137
export const recipeWithGroupedAlternatives = `
132138
---
133139
servings: 1

test/recipe_scaling.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect } from "vitest";
22
import type {
3+
CookwareItem,
34
IngredientAlternative,
45
IngredientItem,
56
IngredientQuantityGroup,
@@ -13,6 +14,7 @@ import {
1314
recipeToScale,
1415
recipeToScaleSomeFixedQuantities,
1516
recipeWithInlineAlternatives,
17+
recipeCookwareScaling,
1618
} from "./fixtures/recipes";
1719
import { recipeWithUnitServings } from "./fixtures/recipes";
1820

@@ -1034,3 +1036,96 @@ Add @cream{1%cup|a pinch%ml}.
10341036
});
10351037
});
10361038
});
1039+
1040+
describe("scaleBy cookware quantities", () => {
1041+
const baseRecipe = new Recipe(recipeCookwareScaling);
1042+
1043+
it("should scale up cookware quantities, rounding up integers and applying minimum 1", () => {
1044+
const scaled = baseRecipe.scaleBy(1.5);
1045+
// bowl{1}: integer → 1 * 1.5 = 1.5 → ceil → 2
1046+
expect(scaled.cookware[0]!.quantity).toEqual({
1047+
type: "fixed",
1048+
value: { type: "decimal", decimal: 2 },
1049+
});
1050+
// sticks{2.5}: non-integer → 2.5 * 1.5 = 3.75, no rounding
1051+
expect(scaled.cookware[1]!.quantity).toEqual({
1052+
type: "fixed",
1053+
value: { type: "decimal", decimal: 3.75 },
1054+
});
1055+
// oven: no quantity → stays undefined
1056+
expect(scaled.cookware[2]!.quantity).toBeUndefined();
1057+
});
1058+
1059+
it("should scale down cookware quantities applying minimum 1 for integers", () => {
1060+
const scaled = baseRecipe.scaleBy(0.5);
1061+
// bowl{1}: integer → 1 * 0.5 = 0.5 → ceil → 1 → max(1, 1) = 1
1062+
expect(scaled.cookware[0]!.quantity).toEqual({
1063+
type: "fixed",
1064+
value: { type: "decimal", decimal: 1 },
1065+
});
1066+
// sticks{2.5}: non-integer → 2.5 * 0.5 = 1.25, no rounding
1067+
expect(scaled.cookware[1]!.quantity).toEqual({
1068+
type: "fixed",
1069+
value: { type: "decimal", decimal: 1.25 },
1070+
});
1071+
// oven: no quantity → stays undefined
1072+
expect(scaled.cookware[2]!.quantity).toBeUndefined();
1073+
});
1074+
1075+
it("should also scale CookwareItem.quantity in steps", () => {
1076+
const scaled = baseRecipe.scaleBy(1.5);
1077+
const step = scaled.sections[0]!.content[0] as Step;
1078+
const bowlItem = step.items[0] as CookwareItem;
1079+
const sticksItem = step.items[1] as CookwareItem;
1080+
const ovenItem = step.items[2] as CookwareItem;
1081+
// bowl: integer → ceil(1.5) = 2
1082+
expect(bowlItem.quantity).toEqual({
1083+
type: "fixed",
1084+
value: { type: "decimal", decimal: 2 },
1085+
});
1086+
// sticks: non-integer → 3.75
1087+
expect(sticksItem.quantity).toEqual({
1088+
type: "fixed",
1089+
value: { type: "decimal", decimal: 3.75 },
1090+
});
1091+
// oven: no quantity
1092+
expect(ovenItem.quantity).toBeUndefined();
1093+
});
1094+
1095+
it("should scale integer range cookware quantities, ceiling each bound", () => {
1096+
const recipe = new Recipe("#pans{1-2}");
1097+
const scaled = recipe.scaleBy(1.5);
1098+
// 1 * 1.5 = 1.5 → ceil → 2; 2 * 1.5 = 3 → ceil → 3
1099+
expect(scaled.cookware[0]!.quantity).toEqual({
1100+
type: "range",
1101+
min: { type: "decimal", decimal: 2 },
1102+
max: { type: "decimal", decimal: 3 },
1103+
});
1104+
});
1105+
1106+
it("should not apply ceiling to non-integer range cookware", () => {
1107+
const recipe = new Recipe("#pans{1.5-2.5}");
1108+
const scaled = recipe.scaleBy(2);
1109+
// non-integer range: 1.5*2=3, 2.5*2=5 — no ceiling
1110+
expect(scaled.cookware[0]!.quantity).toEqual({
1111+
type: "range",
1112+
min: { type: "decimal", decimal: 3 },
1113+
max: { type: "decimal", decimal: 5 },
1114+
});
1115+
});
1116+
1117+
it("should not modify the original recipe when scaling cookware", () => {
1118+
const original = baseRecipe.clone();
1119+
baseRecipe.scaleBy(2);
1120+
expect(baseRecipe).toEqual(original);
1121+
});
1122+
1123+
it("should leave text cookware quantities unchanged", () => {
1124+
const recipe = new Recipe("#bowl{some}");
1125+
const scaled = recipe.scaleBy(3);
1126+
expect(scaled.cookware[0]!.quantity).toEqual({
1127+
type: "fixed",
1128+
value: { type: "text", text: "some" },
1129+
});
1130+
});
1131+
});

0 commit comments

Comments
 (0)