import { formatQuantity } from "./fractions"; /** * Display-time unit conversion for recipe ingredient quantities. * * Recipes are authored in whatever units the author used. This module * converts weight/volume units to the viewing user's `unitPref` at render * time only — stored recipe data is never rewritten. Units that aren't in * the table below (counts, "to taste", "a pinch", "clove", cups/tsp/tbsp, * etc.) are rendered unconverted, since they're either unit-less or * genuinely ambiguous / shared across both measurement systems in cooking * convention. */ export type UnitPref = "metric" | "imperial"; type UnitKind = "weight" | "volume"; type UnitEntry = { system: UnitPref; kind: UnitKind; /** Conversion factor to the kind's base unit (grams for weight, ml for volume). */ factor: number; }; // Only units we actually want to convert live here. Anything else (cup, // tbsp, tsp, "clove", "pinch", "to taste", ...) is intentionally omitted so // it renders unconverted. const UNIT_TABLE: Record = { // metric weight g: { system: "metric", kind: "weight", factor: 1 }, gram: { system: "metric", kind: "weight", factor: 1 }, grams: { system: "metric", kind: "weight", factor: 1 }, kg: { system: "metric", kind: "weight", factor: 1000 }, kilogram: { system: "metric", kind: "weight", factor: 1000 }, kilograms: { system: "metric", kind: "weight", factor: 1000 }, // imperial weight oz: { system: "imperial", kind: "weight", factor: 28.3495 }, ounce: { system: "imperial", kind: "weight", factor: 28.3495 }, ounces: { system: "imperial", kind: "weight", factor: 28.3495 }, lb: { system: "imperial", kind: "weight", factor: 453.592 }, lbs: { system: "imperial", kind: "weight", factor: 453.592 }, pound: { system: "imperial", kind: "weight", factor: 453.592 }, pounds: { system: "imperial", kind: "weight", factor: 453.592 }, // metric volume ml: { system: "metric", kind: "volume", factor: 1 }, milliliter: { system: "metric", kind: "volume", factor: 1 }, milliliters: { system: "metric", kind: "volume", factor: 1 }, millilitre: { system: "metric", kind: "volume", factor: 1 }, millilitres: { system: "metric", kind: "volume", factor: 1 }, l: { system: "metric", kind: "volume", factor: 1000 }, liter: { system: "metric", kind: "volume", factor: 1000 }, liters: { system: "metric", kind: "volume", factor: 1000 }, litre: { system: "metric", kind: "volume", factor: 1000 }, litres: { system: "metric", kind: "volume", factor: 1000 }, // imperial volume (deliberately excludes cup/tbsp/tsp — see note above) "fl oz": { system: "imperial", kind: "volume", factor: 29.5735 }, "fl. oz": { system: "imperial", kind: "volume", factor: 29.5735 }, floz: { system: "imperial", kind: "volume", factor: 29.5735 }, "fluid ounce": { system: "imperial", kind: "volume", factor: 29.5735 }, "fluid ounces": { system: "imperial", kind: "volume", factor: 29.5735 }, qt: { system: "imperial", kind: "volume", factor: 946.353 }, quart: { system: "imperial", kind: "volume", factor: 946.353 }, quarts: { system: "imperial", kind: "volume", factor: 946.353 }, pt: { system: "imperial", kind: "volume", factor: 473.176 }, pint: { system: "imperial", kind: "volume", factor: 473.176 }, pints: { system: "imperial", kind: "volume", factor: 473.176 }, gal: { system: "imperial", kind: "volume", factor: 3785.41 }, gallon: { system: "imperial", kind: "volume", factor: 3785.41 }, gallons: { system: "imperial", kind: "volume", factor: 3785.41 }, }; function normalizeUnit(unit: string): string { return unit.trim().toLowerCase().replace(/\.$/, ""); } function roundTo(value: number, nearest: number): number { return Math.round(value / nearest) * nearest; } /** Formats a metric quantity (g/kg/ml/l), trimming to at most 2 decimals. */ function formatMetricNumber(value: number): string { const rounded = Math.round(value * 100) / 100; return Number.isInteger(rounded) ? String(rounded) : String(parseFloat(rounded.toFixed(2))); } /** * Converts a numeric quantity + unit string to the target unit system, * rounded to sensible cooking precision. Returns null when the unit isn't * in the conversion table, or is already in the target system (nothing to * convert). */ export function convertUnit( value: number, unit: string, targetPref: UnitPref ): { value: string; unit: string } | null { if (!isFinite(value) || value <= 0) return null; const entry = UNIT_TABLE[normalizeUnit(unit)]; if (!entry || entry.system === targetPref) return null; const base = value * entry.factor; if (entry.kind === "weight") { if (targetPref === "metric") { if (base >= 1000) return { value: formatMetricNumber(roundTo(base / 1000, 0.1)), unit: "kg" }; return { value: formatMetricNumber(roundTo(base, base < 20 ? 1 : 5)), unit: "g" }; } const oz = base / 28.3495; if (oz >= 16) return { value: formatQuantity(roundTo(oz / 16, 0.1)), unit: "lb" }; return { value: formatQuantity(roundTo(oz * 4, 1) / 4), unit: "oz" }; } // volume if (targetPref === "metric") { if (base >= 1000) return { value: formatMetricNumber(roundTo(base / 1000, 0.1)), unit: "l" }; return { value: formatMetricNumber(roundTo(base, base < 50 ? 5 : 10)), unit: "ml" }; } const flOz = base / 29.5735; if (flOz >= 32) return { value: formatQuantity(roundTo(flOz / 32, 0.25)), unit: "qt" }; return { value: formatQuantity(roundTo(flOz * 2, 1) / 2), unit: "fl oz" }; } /** * Builds the full display string for an ingredient's quantity + unit, * applying an optional serving-scale factor and, when the unit differs * from the viewer's `unitPref`, appending an approximate converted value — * e.g. "200 g (~7 oz)". The original authored value is always shown first * so it's never lost or hidden. */ export function formatIngredientQuantity( rawQuantity: string | null | undefined, unit: string | null | undefined, targetPref: UnitPref, scale: { base: number; desired: number } = { base: 1, desired: 1 } ): string { if (!rawQuantity) return unit ?? ""; const parsed = parseFloat(rawQuantity); if (isNaN(parsed) || parsed === 0) return unit ?? ""; const scaleFactor = scale.base === 0 ? 1 : scale.desired / scale.base; const scaledValue = parsed * scaleFactor; const originalText = `${formatQuantity(scaledValue)}${unit ? ` ${unit}` : ""}`; if (!unit) return originalText; const converted = convertUnit(scaledValue, unit, targetPref); if (!converted) return originalText; return `${originalText} (~${converted.value} ${converted.unit})`; }