Functional Weave
Code in Rust

hospitality.recipe-scale@1.0.1

impl/typescript.ts

3,699 bytes · the TypeScript implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { convertUnits } from "./units_convert.ts";  ← from units.convert ^1.0.0 · built alongside by fune
import { type RecipeQuantity } from "./hospitality_recipe_scale_types.ts";

const DECIMAL = /^[0-9]+(\.[0-9]+)?$/;
const METRIC: Record<string, [string, string]> = {
  mg: ["g", "kg"],
  g: ["g", "kg"],
  kg: ["g", "kg"],
  mL: ["mL", "L"],
  L: ["mL", "L"],
};

function parseDecimal(text: string): [bigint, bigint] {
  const [whole, fraction = ""] = text.split(".");
  return [BigInt(whole + fraction), 10n ** BigInt(fraction.length)];
}

function halfUp(numerator: bigint, denominator: bigint): bigint {
  return (numerator * 2n + denominator) / (denominator * 2n);
}

function formatScaled(value: bigint, decimals: number): string {
  const unit = 10n ** BigInt(decimals);
  const whole = (value / unit).toString();
  if (decimals === 0) return whole;
  const fraction = (value % unit).toString().padStart(decimals, "0").replace(/0+$/, "");
  return fraction === "" ? whole : `${whole}.${fraction}`;
}

function checkPortions(name: string, value: number): void {
  if (!Number.isInteger(value) || value < 1 || value > 10000) {
    throw new RangeError(`${name} must be a whole number from 1 to 10000, received ${value}`);
  }
}

/**
 * Scale a recipe from one number of portions to another.
 *
 * The scaling is exact (decimal text times a fraction), and each quantity is
 * rounded once, to the precision a kitchen weighs to: whole grams or
 * millilitres from 10 up, one decimal place from 1 to 10, two below 1.
 * Metric amounts move between g and kg, mL and L, at 1000. Counted items
 * (each) round up, since half an egg short is short. Any other unit keeps its
 * name and is rounded to two decimal places.
 */
export function scaleRecipe(ingredients: readonly RecipeQuantity[], fromPortions: number, toPortions: number): readonly RecipeQuantity[] {
  checkPortions("fromPortions", fromPortions);
  checkPortions("toPortions", toPortions);
  const to = BigInt(toPortions);
  const from = BigInt(fromPortions);
  return ingredients.map(({ name, quantity, unit }) => {
    if (typeof quantity !== "string" || !DECIMAL.test(quantity)) {
      throw new RangeError(`quantity must be a non-negative decimal like "12.5", received "${quantity}" for "${name}"`);
    }
    const [whole, fraction = ""] = quantity.split(".");
    const trimmedFraction = fraction.replace(/0+$/, "");
    if ((whole + trimmedFraction).replace(/^0+/, "").length > 15 || trimmedFraction.length > 15) {
      throw new RangeError(`quantity "${quantity}" for "${name}" has too many digits: at most 15 significant digits and 15 decimal places`);
    }
    if (unit === "") throw new RangeError(`unit must not be empty for "${name}"`);

    const metric = METRIC[unit];
    if (metric !== undefined) {
      const [small, large] = metric;
      const [n, scale] = parseDecimal(convertUnits(quantity, unit, small, 12));
      const num = n * to;
      const den = scale * from;
      const decimals = num < den ? 2 : num < den * 10n ? 1 : 0;
      const rounded = halfUp(num * 10n ** BigInt(decimals), den);
      if (decimals === 0 && rounded >= 1000n) {
        return { name, quantity: convertUnits(rounded.toString(), small, large, 3), unit: large };
      }
      return { name, quantity: formatScaled(rounded, decimals), unit: small };
    }
    // Trailing zeros dropped, so "1.000000000000000000000" stays small.
    const [n, scale] = parseDecimal(trimmedFraction === "" ? whole : `${whole}.${trimmedFraction}`);
    const num = n * to;
    const den = scale * from;
    if (unit === "each") {
      return { name, quantity: ((num + den - 1n) / den).toString(), unit };
    }
    return { name, quantity: formatScaled(halfUp(num * 100n, den), 2), unit };
  });
}