planmymeal/app/lib/planner.ts
Cayden Schwechten c19b910c36 init
2026-10-31 20:44:29 -05:00

127 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
};
}