128 lines
5.9 KiB
TypeScript
128 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;
|
|||
|
|
};
|
|||
|
|
}
|