| .idea | ||
| app | ||
| .gitignore | ||
| AGENTS.md | ||
| bun.lock | ||
| eslint.config.mjs | ||
| next.config.ts | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
Plan my Meals: Budget Meal Planner
Plan 1–7 days of meals around a weekly grocery budget and the appliances you actually own. Costs are estimated with whole-package pricing, so the total is what you'd pay at checkout, not a misleading "per-serving" number.
Run it
npm install
npm run dev # http://localhost:3000 (landing) → /plan (planner)
npm test # money math, state validation, meal slots (needs Bun: https://bun.sh)
Production: npm run build && npm start. Deploys to Vercel as-is (no env vars needed).
Requires Node 20.9+ (Next.js 16).
How it works
app/
├── page.tsx Landing page (/): what the app is + how it works; links to /plan
├── plan/page.tsx Planner route (/plan); renders PlannerLoader
├── recipes/page.tsx Recipe library (/recipes): search + filters
├── caydens-corner/page.tsx Cayden's Corner (/caydens-corner): family recipes, by section
├── recipes/[id]/page.tsx One static page per recipe: ingredients, steps, servings scaler
├── actions.ts Server Action: generateMealPlanAction (validates input → planner)
├── components/
│ ├── RecipeDetail.tsx Ingredients (scaled) + instructions; shared by pages, picker and dialog
│ ├── RecipeDialog.tsx Opens when you click a planned meal: recipe + swap/remove
│ ├── RecipeBrowser.tsx Client-side search/filter grid for /recipes
│ ├── SiteHeader.tsx Header for landing + recipe pages
│ ├── RotatingPreview.tsx Landing-page hero card cycling through example meal sets
│ ├── PlannerLoader.tsx Loads the dashboard client-only (state lives in localStorage)
│ ├── PlannerDashboard.tsx Wires state → live summary → children
│ ├── Navbar.tsx Budget meter + save status
│ ├── ConfigPanel.tsx Budget, days, meals/day, people, appliances, cuisines, styles
│ ├── IngredientPanel.tsx Per-ingredient Avoid / Use / Have it switches
│ ├── MealGrid.tsx Day cards with slots (click to pick, drag to swap on desktop)
│ ├── MealSelectorModal.tsx Recipe picker sorted by *marginal* cost to your cart
│ ├── GroceryListDrawer.tsx Cost summary + aisle-grouped list (panel on desktop, sheet on mobile)
│ ├── BudgetMeter.tsx
│ └── Chip.tsx The on/off filter pill used by every filter row
├── lib/
│ ├── catalog.ts Store catalog (average grocery prices as of October 2026)
│ ├── recipes/ 421 recipes across 12 cuisines (see "Adding recipes" below)
│ │ ├── caydens-corner.ts Family recipes (44)
│ │ └── steps/ Cooking instructions, keyed by recipe id
│ ├── display.ts Ingredient lines with scaling + kitchen fractions (¾ cup)
│ ├── sanitize.ts Validates + migrates state (shared by Server Action and localStorage load)
│ ├── pricing.ts PriceProvider interface: the seam for real prices later
│ ├── units.ts Recipe units → package units (cups of rice → grams)
│ ├── grocery.ts Full grocery list, plus Cart: fast "what does this recipe add?"
│ ├── planner.ts Greedy, overlap-aware auto-fill
│ ├── plan.ts Slot helpers, labels, cuisines, "can I cook this?" blockers
│ └── usePlanState.ts Reducer + localStorage persistence
└── types/index.ts
Where the math runs: cost recalculation runs in the browser using the same pure functions as the server, so every slider tweak updates instantly. Generation runs in a Server Action, so a real price API key would stay server-side.
Planner scoring (lower wins, per slot, in day order):
marginal checkout cost + 0.25 × marginal eaten value + $1.50 × times already used + up to $2.50 jitter
Hard rules: only recipes your appliances can cook and that use no avoided ingredients; never the same dish twice in one day; max 2 uses per week (relaxed only if nothing else fits, with a note shown). Preference filters relax style → cuisine if they'd leave slots empty. Appliances and avoided ingredients are never relaxed.
Meal slots are keyed "<day>-<meal>", and meal numbers are fixed: 1 = Breakfast, 2 = Lunch,
3 = Dinner. "Meals per day" only decides which are visible (2 → Lunch + Dinner), the same way "Days"
hides days without deleting them. Clear and Shuffle only touch what's visible. Plans saved by older
versions (which counted from the first visible meal) are converted once on load.
Ingredient preferences
Every catalog item has a three-way setting (Ingredients panel, or "Have it" on any grocery line):
| Setting | Effect |
|---|---|
| Avoid | Recipes using it are never auto-planned; already-planned ones are flagged |
| Use | Default |
| Have it | Stays on the shopping list but costs $0 (pantry staples start here) |
Each aisle also has an Avoid all button, e.g. the Seafood aisle for a fish/shellfish allergy.
Seafood
recipes/seafood.ts holds 13 seafood dishes (plus Shrimp & Grits in the Southern file), all tagged
with the seafood style. Pick 🐟 Seafood under Cooking style (or in the meal picker) to lean the
plan toward fish. All fish and shellfish live in one Seafood aisle on lists. Budget picks: canned
tuna/clams, pollock, tilapia, imitation crab. Splurges: shrimp, salmon.
Swapping in real prices
- Fetch prices server-side (in
actions.tsor a route handler). - Build a
StoreItem[]snapshot and wrap it:new SnapshotPriceProvider("Live grocery prices", items). - Pass it via the
provideroption tobuildGroceryList/ the planner.
Item IDs in recipes.ts reference catalog IDs, so a real provider needs a mapping from those IDs to
store SKUs.
Known limits (v1)
- Prices are estimates based on average grocery prices as of October 2026.
- Drag-to-swap is desktop-only (HTML5 drag & drop doesn't fire on touch); mobile uses tap-to-pick.
- Greedy planning is fast and predictable but not globally optimal.
- Leftover carryover isn't carried into next week's plan yet.
Cayden's Corner
Family recipes: 4 appetizers & sides, 28 mains (one breakfast), 12 desserts.
Every recipe has the caydens_corner style, so it shows up under 🏠 Cayden's Corner in the
library filters, the planner's Cooking style chips and the meal picker. Directions are in the
original wording (recipes/steps/caydens-corner.ts); small clarifications are in [brackets].
Sides and desserts are their own meal types: they're in the library and can be added to a slot by hand, but auto-fill never picks them.
Adding recipes
The big library in app/lib/recipes/extra/ uses a compact one-entry-per-recipe format
(recipes/dish.ts). Ingredients are written like a shopping list:
{ title: "Beef Stroganoff", mins: 30, tools: ["stovetop"], styles: ["comfort"],
about: "Creamy mushroom gravy over egg noodles.",
items: "ground_beef 1 lb; egg_noodles 12 oz; mushrooms 8 oz; sour_cream 0.75 cup" },
- Each ingredient is
<catalog id> <amount> <unit>; units: g, oz, lb, ml, tsp, tbsp, cup, each. - These all throw when the app loads (and so fail
npm run build): unknown catalog ids, units that can't convert to how the item is sold, unknown formats, and duplicate ids. - Cups/tbsp on an item sold by weight need
gramsPerMlon that catalog item. - Add the recipe's steps to the matching file in
recipes/steps/, keyed by its id. The app refuses to load if any recipe has no steps, or if steps exist for a recipe that doesn't. iddefaults to a slug of the title. Set it explicitly before renaming a recipe, or saved plans that used the old id will drop that meal.