import type { FillMode, GeneratePlanResult, MealPlanState, Recipe } from "@/app/types"; import { Cart, buildGroceryList } from "./grocery"; import { activeRecipeIds, activeSlots, isCookable, matchesPreferences, mealKindFor, slotKey, visibleMeals, withoutVisibleSlots } from "./plan"; import { RECIPES } from "./recipes"; // --------------------------------------------------------------------------- // Auto-fill planner: greedy, overlap-aware. // // For each empty slot (in day order), pick the recipe with the LOWEST score: // // score = marginal checkout cost ← what buying this meal adds at the register // + 0.25 × marginal eaten value ← tie-breaker: prefer genuinely cheap food // + repeat penalty × times used ← keeps the week from being 14× rice and beans // + small seeded jitter ← "Shuffle" gives a different but still cheap plan // // Marginal cost is measured against the cart so far, with whole packages. // A dish that reuses an already-bought bag of rice therefore scores as nearly // free, which is exactly the ingredient-overlap behavior we want. // // Greedy isn't globally optimal, but it's predictable, fast (a few ms for a // full week), and easy to reason about. That's the right trade for v1. // --------------------------------------------------------------------------- const REPEAT_PENALTY = 1.5; // dollars of "boredom" per previous use const MAX_USES_PER_WEEK = 2; // hard cap, relaxed only if nothing else fits const JITTER = 2.5; // dollars of randomness per pick export function generatePlan(state: MealPlanState, fillMode: FillMode, seed: number): GeneratePlanResult { const random = mulberry32(seed); const notes = new Set(); const groceryOptions = { householdSize: state.householdSize, onHandItems: state.onHandItems }; // "empty": keep every pick and fill the gaps. "all" (Shuffle): re-plan // the visible grid, but keep meals on hidden days/meals untouched. const slots: Record = fillMode === "empty" ? { ...state.slots } : withoutVisibleSlots(state); // Only the visible plan is bought and eaten, so only it goes in the cart. const plannedIds = activeRecipeIds({ ...state, slots }); const cart = new Cart(groceryOptions, plannedIds); const usage = new Map(); for (const id of plannedIds) usage.set(id, (usage.get(id) ?? 0) + 1); // What this kitchen can cook never changes during a run, so work it out once. const cookable = { breakfast: RECIPES.filter((r) => r.kind === "breakfast" && isCookable(r, state)), main: RECIPES.filter((r) => r.kind === "main" && isCookable(r, state)), }; for (const slot of activeSlots({ ...state, slots })) { if (slot.recipeId) continue; const kind = mealKindFor(slot.mealNumber); const pool = pickCandidatePool(cookable[kind], state, usage, notes); // Never the same dish twice in one day (next-day leftovers are fine), unless nothing else fits. const todaysRecipes = new Set(visibleMeals(state.mealsPerDay).map((meal) => slots[slotKey(slot.dayIndex, meal)])); const notToday = pool.filter((r) => !todaysRecipes.has(r.id)); const candidates = notToday.length > 0 ? notToday : pool; if (candidates.length === 0) { notes.add(`No ${kind} recipes fit your appliances and avoided ingredients. Some slots were left empty.`); continue; } let best: { recipe: Recipe; score: number } | null = null; for (const recipe of candidates) { const marginal = cart.marginal(recipe.id); const score = marginal.checkout + 0.25 * marginal.eaten + REPEAT_PENALTY * (usage.get(recipe.id) ?? 0) + random() * JITTER; if (!best || score < best.score) best = { recipe, score }; } const pick = best!.recipe.id; slots[slotKey(slot.dayIndex, slot.mealNumber)] = pick; cart.add(pick); usage.set(pick, (usage.get(pick) ?? 0) + 1); } const summary = buildGroceryList(activeRecipeIds({ ...state, slots }), groceryOptions); if (summary.checkoutTotal > state.targetBudget) { notes.add( `The cheapest plan we found is $${summary.checkoutTotal.toFixed(2)}, over your $${state.targetBudget} budget. ` + "Try fewer meals, more appliances, or looser filters.", ); } return { slots, summary, notes: [...notes] }; } /** * Candidate pool, loosening filters step by step until something fits: * 1. appliances + cuisine + style, under the repeat cap * 2. appliances + cuisine, under the repeat cap * 3. appliances only, under the repeat cap * 4. appliances only, repeat cap ignored * Appliances and avoided ingredients are NEVER relaxed: suggesting an oven * dish to someone without an oven (or beef to someone avoiding it) is worse * than an empty slot. */ function pickCandidatePool(cookable: Recipe[], state: MealPlanState, usage: Map, notes: Set): Recipe[] { const underCap = (r: Recipe) => (usage.get(r.id) ?? 0) < MAX_USES_PER_WEEK; const tiers: { pool: Recipe[]; note?: string }[] = [ { pool: cookable.filter((r) => matchesPreferences(r, state.preferredCuisines, state.preferredStyles) && underCap(r)) }, { pool: cookable.filter((r) => matchesPreferences(r, state.preferredCuisines, []) && underCap(r)), note: "Relaxed your cooking-style filter to fill every slot." }, { pool: cookable.filter(underCap), note: "Relaxed your cuisine filter to fill every slot." }, { pool: cookable, note: "Repeated some recipes more than twice; there aren't enough matches for this many meals." }, ]; for (const tier of tiers) { if (tier.pool.length > 0) { if (tier.note) notes.add(tier.note); return tier.pool; } } return []; } /** Tiny deterministic PRNG so the same seed always gives the same plan. */ function mulberry32(seed: number): () => number { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }