/// import { type Cell, cell, Default, handler, lift, recipe } from "commontools"; interface IngredientInput { name?: unknown; quantity?: unknown; unit?: unknown; } interface Ingredient { name: string; quantity: number; unit: string; } interface ServingsEvent { servings?: number; delta?: number; } const defaultRecipeName = "Herb Pasta"; const defaultBaseServings = 4; const defaultDesiredServings = 4; const defaultIngredients: Ingredient[] = [ { name: "Spaghetti", quantity: 200, unit: "gram" }, { name: "Cherry Tomato", quantity: 150, unit: "gram" }, { name: "Olive Oil", quantity: 2, unit: "tbsp" }, { name: "Basil", quantity: 6, unit: "leaf" }, ]; interface RecipeIngredientScalerArgs { name: Default; baseServings: Default; desiredServings: Default; ingredients: Default; } const formatNumber = (value: number): string => { const fixed = value.toFixed(2); return fixed.replace(/\.00$/, "").replace(/(\.\d)0$/, "$1"); }; const sanitizeServings = ( value: unknown, fallback: number, minimum = 0.5, ): number => { if (typeof value !== "number" || !Number.isFinite(value)) { return Math.max(fallback, minimum); } const rounded = Math.round(value * 10) / 10; if (rounded < minimum) { return Math.max(fallback, minimum); } return rounded; }; const sanitizeQuantity = (value: unknown): number => { if (typeof value !== "number" || !Number.isFinite(value)) return 0; if (value <= 0) return 0; return Math.round(value * 100) / 100; }; const textOrNull = (value: unknown): string | null => { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; }; const sanitizeIngredientList = (value: unknown): Ingredient[] => { if (!Array.isArray(value)) { return defaultIngredients.map((item) => ({ ...item })); } const result: Ingredient[] = []; for (const entry of value as IngredientInput[]) { const name = textOrNull(entry?.name); if (!name) continue; const unit = textOrNull(entry?.unit) ?? "unit"; const quantity = sanitizeQuantity(entry?.quantity); if (quantity <= 0) continue; result.push({ name, unit, quantity }); } return result.length > 0 ? result : defaultIngredients.map((item) => ({ ...item })); }; const toMultiplier = (desired: number, base: number): number => { if (base <= 0) return 1; return Math.round((desired / base) * 100) / 100; }; const appendHistory = (history: Cell, entry: string) => { const current = history.get(); const list = Array.isArray(current) ? current : []; history.set([...list, entry]); }; const setServings = handler( ( event: ServingsEvent | undefined, context: { desiredServings: Cell; baseServings: Cell; history: Cell; }, ) => { const base = sanitizeServings( context.baseServings.get(), defaultBaseServings, 1, ); const next = sanitizeServings(event?.servings, base, 0.5); context.desiredServings.set(next); const multiplier = toMultiplier(next, base); const message = `Set servings to ${formatNumber(next)} (x${ formatNumber(multiplier) })`; appendHistory(context.history, message); }, ); const adjustServings = handler( ( event: ServingsEvent | undefined, context: { desiredServings: Cell; baseServings: Cell; history: Cell; }, ) => { const base = sanitizeServings( context.baseServings.get(), defaultBaseServings, 1, ); const current = sanitizeServings( context.desiredServings.get(), base, 0.5, ); const delta = typeof event?.delta === "number" && Number.isFinite(event.delta) ? event.delta : 0; const next = sanitizeServings(current + delta, base, 0.5); context.desiredServings.set(next); const multiplier = toMultiplier(next, base); const message = `Adjusted by ${formatNumber(delta)} -> ${formatNumber(next)} ` + `(x${formatNumber(multiplier)})`; appendHistory(context.history, message); }, ); export const recipeIngredientScaler = recipe( "Recipe Ingredient Scaler", ({ name, baseServings, desiredServings, ingredients }) => { const history = cell([]); const recipeName = lift((value: string | undefined) => { const normalized = value?.trim(); return normalized && normalized.length > 0 ? normalized : defaultRecipeName; })(name); const baseView = lift((value: number | undefined) => sanitizeServings(value, defaultBaseServings, 1) )(baseServings); const desiredView = lift((input: { desired: number | undefined; base: number; }) => sanitizeServings(input.desired, input.base, 0.5))({ desired: desiredServings, base: baseView, }); const ingredientsView = lift(sanitizeIngredientList)(ingredients); const multiplier = lift((input: { desired: number; base: number }) => toMultiplier(input.desired, input.base) )({ desired: desiredView, base: baseView, }); const scaledIngredients = lift((input: { items: Ingredient[]; multiplier: number; }) => input.items.map((item) => ({ name: item.name, unit: item.unit, quantity: Math.round(item.quantity * input.multiplier * 100) / 100, })) )({ items: ingredientsView, multiplier, }); const desiredLabel = lift(formatNumber)(desiredView); const multiplierLabel = lift((value: number) => `x${formatNumber(value)}`)( multiplier, ); const scaledLabel = lift((items: Ingredient[]) => items.map((item) => `${item.name}: ${formatNumber(item.quantity)} ${item.unit}` ).join("; ") )(scaledIngredients); const summary = lift((input: { name: string; servings: string; multiplier: string; }) => `${input.name}: ${input.servings} servings (${input.multiplier})`)({ name: recipeName, servings: desiredLabel, multiplier: multiplierLabel, }); return { recipeName, baseServings: baseView, desiredServings: desiredView, desiredLabel, multiplier, multiplierLabel, ingredients: ingredientsView, scaledIngredients, scaledLabel, summary, history, setServings: setServings({ desiredServings, baseServings, history, }), adjustServings: adjustServings({ desiredServings, baseServings, history, }), }; }, );