/// import { type Cell, cell, Default, handler, lift, recipe, str, } from "commontools"; const defaultDays = [ "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", ]; type MealSlot = "breakfast" | "lunch" | "dinner"; interface Ingredient { name: string; quantity: number; unit: string; } interface RecipeDefinition { name: string; ingredients: Ingredient[]; } interface PlannedMeal { day: string; meal: MealSlot; recipe: string; } const defaultRecipes: RecipeDefinition[] = [ { name: "Oatmeal Bowl", ingredients: [ { name: "Rolled Oats", quantity: 1, unit: "cup" }, { name: "Milk", quantity: 1, unit: "cup" }, { name: "Berries", quantity: 0.5, unit: "cup" }, ], }, { name: "Veggie Curry", ingredients: [ { name: "Chickpeas", quantity: 1, unit: "can" }, { name: "Spinach", quantity: 2, unit: "cup" }, { name: "Curry Paste", quantity: 2, unit: "tbsp" }, ], }, { name: "Quinoa Salad", ingredients: [ { name: "Quinoa", quantity: 1, unit: "cup" }, { name: "Cherry Tomato", quantity: 1, unit: "cup" }, { name: "Olive Oil", quantity: 2, unit: "tbsp" }, ], }, ]; interface MenuPlannerArgs { days: Default; recipes: Default; plan: Default; } interface AssignmentEvent { day?: string; meal?: string; recipe?: string; } interface ShoppingEntry { name: string; unit: string; quantity: number; } const sanitizeText = (value: unknown): string | null => { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; }; const sanitizeDays = (value: readonly string[] | undefined): string[] => { if (!Array.isArray(value)) return [...defaultDays]; const seen = new Set(); const result: string[] = []; for (const entry of value) { const sanitized = sanitizeText(entry); if (!sanitized) continue; const normalized = sanitized[0].toUpperCase() + sanitized.slice(1); if (!seen.has(normalized)) { seen.add(normalized); result.push(normalized); } } return result.length > 0 ? result : [...defaultDays]; }; const sanitizeIngredient = ( value: Ingredient | undefined, ): Ingredient | null => { const name = sanitizeText(value?.name); if (!name) return null; const unit = sanitizeText(value?.unit) ?? "unit"; const quantity = typeof value?.quantity === "number" && Number.isFinite(value.quantity) ? Math.max(Math.round(value.quantity * 100) / 100, 0) : 0; return { name, unit, quantity }; }; const sanitizeRecipes = ( value: readonly RecipeDefinition[] | undefined, ): RecipeDefinition[] => { if (!Array.isArray(value) || value.length === 0) { return structuredClone(defaultRecipes); } const recipes: RecipeDefinition[] = []; const seen = new Set(); for (const entry of value) { const name = sanitizeText(entry?.name); if (!name || seen.has(name)) continue; const ingredients: Ingredient[] = []; if (Array.isArray(entry?.ingredients)) { for (const item of entry.ingredients) { const sanitized = sanitizeIngredient(item); if (sanitized && sanitized.quantity > 0) { ingredients.push(sanitized); } } } if (ingredients.length > 0) { seen.add(name); recipes.push({ name, ingredients }); } } return recipes.length > 0 ? recipes : structuredClone(defaultRecipes); }; const sanitizeMealSlot = (value: unknown): MealSlot => { const normalized = sanitizeText(value)?.toLowerCase(); if (normalized === "lunch" || normalized === "dinner") { return normalized; } return "breakfast"; }; const sanitizePlan = ( value: readonly PlannedMeal[] | undefined, days: readonly string[], recipes: readonly RecipeDefinition[], ): PlannedMeal[] => { if (!Array.isArray(value)) return []; const validDays = new Set(days); const validRecipes = new Set(recipes.map((entry) => entry.name)); const result: PlannedMeal[] = []; for (const entry of value) { const day = sanitizeText(entry?.day); if (!day || !validDays.has(day)) continue; const meal = sanitizeMealSlot(entry?.meal); const recipe = sanitizeText(entry?.recipe); if (!recipe || !validRecipes.has(recipe)) continue; result.push({ day, meal, recipe }); } return result; }; const buildRecipeMap = (recipes: readonly RecipeDefinition[]) => { const map = new Map(); for (const entry of recipes) { map.set(entry.name, entry.ingredients); } return map; }; const aggregateShopping = ( plan: readonly PlannedMeal[], recipes: readonly RecipeDefinition[], ): ShoppingEntry[] => { const recipeMap = buildRecipeMap(recipes); const totals = new Map(); for (const item of plan) { const ingredients = recipeMap.get(item.recipe); if (!ingredients) continue; for (const ingredient of ingredients) { const key = `${ingredient.name}__${ingredient.unit}`; const previous = totals.get(key); const quantity = (previous?.quantity ?? 0) + ingredient.quantity; totals.set(key, { name: ingredient.name, unit: ingredient.unit, quantity: Math.round(quantity * 100) / 100, }); } } const entries = Array.from(totals.values()); entries.sort((left, right) => left.name.localeCompare(right.name)); return entries; }; const ensurePlanStructure = ( days: readonly string[], plan: readonly PlannedMeal[], ): Record> => { const structure: Record> = {}; for (const day of days) { structure[day] = { breakfast: "", lunch: "", dinner: "" }; } for (const entry of plan) { if (!structure[entry.day]) continue; structure[entry.day][entry.meal] = entry.recipe; } return structure; }; export const menuPlanner = recipe( "Menu Planner", ({ days, recipes, plan }) => { const sequence = cell(0); const lastAction = cell("initialized"); const daysView = lift(sanitizeDays)(days); const recipesView = lift(sanitizeRecipes)(recipes); const planView = lift((inputs: { plan: PlannedMeal[] | undefined; days: string[]; recipes: RecipeDefinition[]; }) => sanitizePlan(inputs.plan, inputs.days, inputs.recipes))({ plan, days: daysView, recipes: recipesView, }); const planByDay = lift((inputs: { days: string[]; plan: PlannedMeal[]; }) => ensurePlanStructure(inputs.days, inputs.plan))({ days: daysView, plan: planView, }); const shoppingList = lift((inputs: { plan: PlannedMeal[]; recipes: RecipeDefinition[]; }) => aggregateShopping(inputs.plan, inputs.recipes))({ plan: planView, recipes: recipesView, }); const plannedCount = lift((entries: PlannedMeal[] | undefined) => Array.isArray(entries) ? entries.length : 0 )(planView); const status = str`${plannedCount} meals scheduled`; const convertContext = { plan, daysView, recipesView, planView, sequence, lastAction, } as const; const assignMeal = handler( ( event: AssignmentEvent | undefined, context: { plan: Cell; daysView: Cell; recipesView: Cell; planView: Cell; sequence: Cell; lastAction: Cell; }, ) => { const dayList = context.daysView.get(); const recipeList = context.recipesView.get(); const validDays = new Set(dayList); const validRecipes = new Set(recipeList.map((entry) => entry.name)); const day = sanitizeText(event?.day) ?? dayList[0]; if (!validDays.has(day)) return; const meal = sanitizeMealSlot(event?.meal); const recipe = sanitizeText(event?.recipe); if (!recipe || !validRecipes.has(recipe)) return; const current = sanitizePlan( context.plan.get(), dayList, recipeList, ); const filtered = current.filter((entry) => !(entry.day === day && entry.meal === meal) ); filtered.push({ day, meal, recipe }); context.plan.set(filtered); const sequenceValue = (context.sequence.get() ?? 0) + 1; context.sequence.set(sequenceValue); const action = `Assigned ${recipe} to ${day} ${meal}`; context.lastAction.set(action); }, ); const clearMeal = handler( ( event: AssignmentEvent | undefined, context: { plan: Cell; daysView: Cell; recipesView: Cell; planView: Cell; sequence: Cell; lastAction: Cell; }, ) => { const dayList = context.daysView.get(); const recipeList = context.recipesView.get(); const day = sanitizeText(event?.day) ?? dayList[0]; if (!dayList.includes(day)) return; const meal = sanitizeMealSlot(event?.meal); const current = sanitizePlan( context.plan.get(), dayList, recipeList, ); const filtered = current.filter((entry) => !(entry.day === day && entry.meal === meal) ); context.plan.set(filtered); const sequenceValue = (context.sequence.get() ?? 0) + 1; context.sequence.set(sequenceValue); const action = `Cleared ${day} ${meal}`; context.lastAction.set(action); }, ); return { days, recipes, plan, planByDay, shoppingList, plannedCount, status, lastAction, assignMeal: assignMeal(convertContext as never), clearMeal: clearMeal(convertContext as never), }; }, );