This commit is contained in:
Cayden Schwechten 2026-10-31 20:44:29 -05:00
commit c19b910c36
80 changed files with 16134 additions and 0 deletions

127
app/lib/planner.ts Normal file
View file

@ -0,0 +1,127 @@
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;
};
}