127 lines
5.9 KiB
TypeScript
127 lines
5.9 KiB
TypeScript
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<string>();
|
||
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<string, string> = 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<string, number>();
|
||
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<string, number>, notes: Set<string>): 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;
|
||
};
|
||
}
|