From c19b910c36fc4d7fe3908d111a47bbec4cf3ac2f Mon Sep 17 00:00:00 2001 From: Cayden Schwechten Date: Sat, 31 Oct 2026 20:44:29 -0500 Subject: [PATCH] init --- .gitignore | 41 + .idea/.gitignore | 10 + .idea/cat-activity.xml | 7 + .idea/helpmeeat.iml | 8 + .idea/inspectionProfiles/Project_Default.xml | 13 + .idea/jsLibraryMappings.xml | 6 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + AGENTS.md | 9 + README.md | 144 + app/actions.ts | 27 + app/caydens-corner/page.tsx | 108 + app/components/AddToPlanControl.tsx | 146 + app/components/BudgetMeter.tsx | 40 + app/components/Chip.tsx | 38 + app/components/ConfigPanel.tsx | 272 + app/components/GroceryListDrawer.tsx | 221 + app/components/Icons.tsx | 58 + app/components/IngredientPanel.tsx | 199 + app/components/MealGrid.tsx | 231 + app/components/MealSelectorModal.tsx | 297 + app/components/Navbar.tsx | 36 + app/components/OverlapExplorer.tsx | 149 + app/components/PlannerDashboard.tsx | 246 + app/components/PlannerLoader.tsx | 31 + app/components/RadioGroup.tsx | 95 + app/components/RecipeBrowser.tsx | 231 + app/components/RecipeDetail.tsx | 229 + app/components/RecipeDialog.tsx | 111 + app/components/RecipeServings.tsx | 101 + app/components/RotatingPreview.tsx | 123 + app/components/SiteHeader.tsx | 109 + app/components/ThemeToggle.tsx | 53 + app/favicon.ico | Bin 0 -> 15086 bytes app/globals.css | 194 + app/layout.tsx | 44 + app/lib/catalog.ts | 318 + app/lib/display.ts | 58 + app/lib/grocery.test.ts | 112 + app/lib/grocery.ts | 183 + app/lib/plan.test.ts | 93 + app/lib/plan.ts | 150 + app/lib/planner.ts | 127 + app/lib/pricing.ts | 41 + app/lib/recipes/breakfasts.ts | 311 + app/lib/recipes/caydens-corner.ts | 70 + app/lib/recipes/dish.ts | 79 + app/lib/recipes/extra/americas-north.ts | 75 + app/lib/recipes/extra/americas-south.ts | 46 + app/lib/recipes/extra/asia.ts | 86 + app/lib/recipes/extra/breakfasts.ts | 53 + app/lib/recipes/extra/mediterranean.ts | 59 + app/lib/recipes/extra/seafood.ts | 24 + app/lib/recipes/index.ts | 112 + app/lib/recipes/ing.ts | 9 + app/lib/recipes/mains-americas.ts | 303 + app/lib/recipes/mains-asia.ts | 307 + app/lib/recipes/mains-mediterranean.ts | 181 + app/lib/recipes/seafood.ts | 170 + app/lib/recipes/steps/caydens-corner.ts | 321 + app/lib/recipes/steps/core.ts | 113 + app/lib/recipes/steps/extra-americas.ts | 110 + app/lib/recipes/steps/extra-asia.ts | 81 + app/lib/recipes/steps/extra-breakfasts.ts | 49 + app/lib/recipes/steps/extra-mediterranean.ts | 56 + app/lib/recipes/steps/extra-seafood.ts | 22 + app/lib/sanitize.ts | 106 + app/lib/units.ts | 54 + app/lib/usePlanState.ts | 128 + app/page.tsx | 288 + app/plan/page.tsx | 12 + app/recipes/[id]/page.tsx | 161 + app/recipes/page.tsx | 45 + app/types/index.ts | 181 + bun.lock | 927 +++ eslint.config.mjs | 18 + next.config.ts | 17 + package-lock.json | 6774 ++++++++++++++++++ package.json | 29 + tsconfig.json | 34 + 80 files changed, 16134 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/cat-activity.xml create mode 100644 .idea/helpmeeat.iml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/jsLibraryMappings.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 app/actions.ts create mode 100644 app/caydens-corner/page.tsx create mode 100644 app/components/AddToPlanControl.tsx create mode 100644 app/components/BudgetMeter.tsx create mode 100644 app/components/Chip.tsx create mode 100644 app/components/ConfigPanel.tsx create mode 100644 app/components/GroceryListDrawer.tsx create mode 100644 app/components/Icons.tsx create mode 100644 app/components/IngredientPanel.tsx create mode 100644 app/components/MealGrid.tsx create mode 100644 app/components/MealSelectorModal.tsx create mode 100644 app/components/Navbar.tsx create mode 100644 app/components/OverlapExplorer.tsx create mode 100644 app/components/PlannerDashboard.tsx create mode 100644 app/components/PlannerLoader.tsx create mode 100644 app/components/RadioGroup.tsx create mode 100644 app/components/RecipeBrowser.tsx create mode 100644 app/components/RecipeDetail.tsx create mode 100644 app/components/RecipeDialog.tsx create mode 100644 app/components/RecipeServings.tsx create mode 100644 app/components/RotatingPreview.tsx create mode 100644 app/components/SiteHeader.tsx create mode 100644 app/components/ThemeToggle.tsx create mode 100644 app/favicon.ico create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/lib/catalog.ts create mode 100644 app/lib/display.ts create mode 100644 app/lib/grocery.test.ts create mode 100644 app/lib/grocery.ts create mode 100644 app/lib/plan.test.ts create mode 100644 app/lib/plan.ts create mode 100644 app/lib/planner.ts create mode 100644 app/lib/pricing.ts create mode 100644 app/lib/recipes/breakfasts.ts create mode 100644 app/lib/recipes/caydens-corner.ts create mode 100644 app/lib/recipes/dish.ts create mode 100644 app/lib/recipes/extra/americas-north.ts create mode 100644 app/lib/recipes/extra/americas-south.ts create mode 100644 app/lib/recipes/extra/asia.ts create mode 100644 app/lib/recipes/extra/breakfasts.ts create mode 100644 app/lib/recipes/extra/mediterranean.ts create mode 100644 app/lib/recipes/extra/seafood.ts create mode 100644 app/lib/recipes/index.ts create mode 100644 app/lib/recipes/ing.ts create mode 100644 app/lib/recipes/mains-americas.ts create mode 100644 app/lib/recipes/mains-asia.ts create mode 100644 app/lib/recipes/mains-mediterranean.ts create mode 100644 app/lib/recipes/seafood.ts create mode 100644 app/lib/recipes/steps/caydens-corner.ts create mode 100644 app/lib/recipes/steps/core.ts create mode 100644 app/lib/recipes/steps/extra-americas.ts create mode 100644 app/lib/recipes/steps/extra-asia.ts create mode 100644 app/lib/recipes/steps/extra-breakfasts.ts create mode 100644 app/lib/recipes/steps/extra-mediterranean.ts create mode 100644 app/lib/recipes/steps/extra-seafood.ts create mode 100644 app/lib/sanitize.ts create mode 100644 app/lib/units.ts create mode 100644 app/lib/usePlanState.ts create mode 100644 app/page.tsx create mode 100644 app/plan/page.tsx create mode 100644 app/recipes/[id]/page.tsx create mode 100644 app/recipes/page.tsx create mode 100644 app/types/index.ts create mode 100644 bun.lock create mode 100644 eslint.config.mjs create mode 100644 next.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/cat-activity.xml b/.idea/cat-activity.xml new file mode 100644 index 0000000..7eadaf0 --- /dev/null +++ b/.idea/cat-activity.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/.idea/helpmeeat.iml b/.idea/helpmeeat.iml new file mode 100644 index 0000000..c956989 --- /dev/null +++ b/.idea/helpmeeat.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..b825bb7 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/.idea/jsLibraryMappings.xml b/.idea/jsLibraryMappings.xml new file mode 100644 index 0000000..d23208f --- /dev/null +++ b/.idea/jsLibraryMappings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..d43f603 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..af3b7c8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ + + +## This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..cbf1217 --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +# 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 + +```bash +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 `"-"`, 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 + +1. Fetch prices server-side (in `actions.ts` or a route handler). +2. Build a `StoreItem[]` snapshot and wrap it: `new SnapshotPriceProvider("Live grocery prices", items)`. +3. Pass it via the `provider` option to `buildGroceryList` / 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: + +```ts +{ 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 ` `; 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 `gramsPerMl` on 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. +- `id` defaults 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. diff --git a/app/actions.ts b/app/actions.ts new file mode 100644 index 0000000..46e8b4c --- /dev/null +++ b/app/actions.ts @@ -0,0 +1,27 @@ +"use server"; + +import type { FillMode, GeneratePlanInput, GeneratePlanResult } from "@/app/types"; +import { generatePlan } from "@/app/lib/planner"; +import { sanitizeState } from "@/app/lib/sanitize"; + +// --------------------------------------------------------------------------- +// Server Actions. +// +// Note on the split: live cost recalculation runs in the BROWSER (same pure +// functions from lib/), so every slider tweak updates instantly with no round +// trip. Generation runs HERE so that, once real prices exist, the price API +// key and fetch live server-side only. +// +// Server Actions are public HTTP endpoints, so input goes through the same +// sanitizer as saved state (lib/sanitize.ts) rather than being trusted. +// --------------------------------------------------------------------------- + +export async function generateMealPlanAction(input: GeneratePlanInput): Promise { + const state = sanitizeState(input?.state); + const fillMode: FillMode = input?.fillMode === "all" ? "all" : "empty"; + const seed = Number.isFinite(input?.seed) ? Math.floor(input.seed) : Date.now(); + + // Real price lookups would go here: fetch → build a SnapshotPriceProvider + // → pass it through generatePlan/buildGroceryList. + return generatePlan(state, fillMode, seed); +} diff --git a/app/caydens-corner/page.tsx b/app/caydens-corner/page.tsx new file mode 100644 index 0000000..85191e9 --- /dev/null +++ b/app/caydens-corner/page.tsx @@ -0,0 +1,108 @@ +import { ArrowRight, BookHeart } from "lucide-react"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { ViewTransition } from "react"; +import { ApplianceIcon } from "@/app/components/Icons"; +import { SiteHeader } from "@/app/components/SiteHeader"; +import { APPLIANCES } from "@/app/lib/plan"; +import { RECIPES } from "@/app/lib/recipes"; +import type { Recipe } from "@/app/types"; + +export const metadata: Metadata = { + title: "Cayden's Corner", + description: "Homemade family recipes: tried, tested and loved.", +}; + +const FAMILY = RECIPES.filter((r) => r.styles.includes("caydens_corner")); + +const SECTIONS: { title: string; recipes: Recipe[] }[] = [ + { title: "Appetizers & Side Dishes", recipes: FAMILY.filter((r) => r.kind === "side") }, + { title: "Main Dishes", recipes: FAMILY.filter((r) => r.kind === "main" || r.kind === "breakfast") }, + { title: "Desserts & Bakes", recipes: FAMILY.filter((r) => r.kind === "dessert") }, +].map((s) => ({ ...s, recipes: [...s.recipes].sort((a, b) => a.title.localeCompare(b.title)) })); + +export default function CaydensCornerPage() { + return ( +
+ +
+
+

+ + Cayden's Corner · Heirloom Cookbook +

+

+ Family Recipes +

+

+ {FAMILY.length} homemade family favorites—passed down, tested at the dinner table, and loved. Comfort classics, + holiday staples, and plenty of scratch-baked desserts. +

+
+

+ Written originally for a table of four with leftovers; the planner scales every ingredient automatically to your household. +

+ + Plan a family-recipe week + + +
+
+ + {SECTIONS.map((section) => ( +
+
+

+ {section.title} +

+ {section.recipes.length} recipes +
+ +
    + {section.recipes.map((r) => { + const applianceNames = + r.requiredAppliances.map((a) => APPLIANCES.find((x) => x.id === a)?.label).join(", ") || "No cooking"; + return ( +
  • + + + + {r.title} + + + {r.description} + + + + {r.prepTime >= 120 ? `${Math.round(r.prepTime / 60)} hr` : `${r.prepTime} min`} · serves {r.servings} + + + {r.requiredAppliances.length > 0 ? ( + <> + {r.requiredAppliances.map((a) => ( + + ))} + Requires {applianceNames} + + ) : ( + No cooking + )} + + + +
  • + ); + })} +
+
+ ))} +
+
+ ); +} diff --git a/app/components/AddToPlanControl.tsx b/app/components/AddToPlanControl.tsx new file mode 100644 index 0000000..ab9f185 --- /dev/null +++ b/app/components/AddToPlanControl.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { ArrowRight, CalendarPlus, Check, X } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useRef, useState } from "react"; +import { DAY_NAMES, mealLabel, slotKey, visibleMeals } from "@/app/lib/plan"; +import { RECIPES_BY_ID } from "@/app/lib/recipes"; +import { DEFAULT_STATE, sanitizeState } from "@/app/lib/sanitize"; +import type { MealPlanState } from "@/app/types"; + +const STORAGE_KEY = "budget-meal-planner:v2"; + +function readPlan(): MealPlanState { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + return raw ? sanitizeState(JSON.parse(raw)) : DEFAULT_STATE; + } catch { + return DEFAULT_STATE; + } +} + +export function AddToPlanControl({ recipeId, recipeTitle }: { recipeId: string; recipeTitle: string }) { + const [open, setOpen] = useState(false); + const [plan, setPlan] = useState(DEFAULT_STATE); + const [addedLabel, setAddedLabel] = useState(null); + const dialogRef = useRef(null); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + if (open && !dialog.open) dialog.showModal(); + if (!open && dialog.open) dialog.close(); + return () => { + if (dialog.open) dialog.close(); + }; + }, [open]); + + function assignSlot(day: number, meal: number) { + const latest = readPlan(); + const key = slotKey(day, meal); + const updated: MealPlanState = { + ...latest, + slots: { ...latest.slots, [key]: recipeId }, + }; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + setPlan(updated); + setAddedLabel(`${DAY_NAMES[day]} · ${mealLabel(meal)}`); + setOpen(false); + } catch { + // Blocked storage + } + } + + const meals = visibleMeals(plan.mealsPerDay); + + return ( +
+ + + {addedLabel && ( + + + Added to {addedLabel} + + Open planner + + + + )} + + setOpen(false)} + onClick={(e) => e.target === dialogRef.current && setOpen(false)} + aria-labelledby="add-to-plan-title" + className="m-0 mt-auto max-h-[85dvh] w-full max-w-none overflow-y-auto rounded-t-2xl border border-line bg-surface p-0 text-ink shadow-2xl sm:m-auto sm:max-w-lg sm:rounded-2xl" + > +
+
+
+

Assign slot

+

+ Add “{recipeTitle}” +

+
+ +
+ +
+ {Array.from({ length: plan.daysCount }, (_, day) => ( +
+

{DAY_NAMES[day]}

+
+ {meals.map((meal) => { + const key = slotKey(day, meal); + const existingId = plan.slots[key]; + const existingTitle = existingId ? RECIPES_BY_ID.get(existingId)?.title : null; + const isSame = existingId === recipeId; + + return ( + + ); + })} +
+
+ ))} +
+
+
+
+ ); +} diff --git a/app/components/BudgetMeter.tsx b/app/components/BudgetMeter.tsx new file mode 100644 index 0000000..d38454b --- /dev/null +++ b/app/components/BudgetMeter.tsx @@ -0,0 +1,40 @@ +import { formatUsd } from "@/app/lib/units"; + +/** Herb sage under 85%, saffron amber up to 100%, chili red over budget. */ +function budgetTone(spent: number, budget: number) { + const ratio = budget > 0 ? spent / budget : 0; + if (ratio > 1) return { bar: "bg-status-danger", text: "text-status-danger", label: "Over budget" }; + if (ratio > 0.85) return { bar: "bg-status-warn", text: "text-status-warn", label: "Close to budget" }; + return { bar: "bg-status-ok", text: "text-status-ok", label: "Under budget" }; +} + +interface BudgetMeterProps { + spent: number; + budget: number; + compact?: boolean; +} + +export function BudgetMeter({ spent, budget, compact = false }: BudgetMeterProps) { + const tone = budgetTone(spent, budget); + const percent = budget > 0 ? Math.min(100, (spent / budget) * 100) : 0; + + return ( +
+
+ {formatUsd(spent)} + / {formatUsd(budget)} +
+
+
+
+
+ ); +} diff --git a/app/components/Chip.tsx b/app/components/Chip.tsx new file mode 100644 index 0000000..fbc73c2 --- /dev/null +++ b/app/components/Chip.tsx @@ -0,0 +1,38 @@ +import { Check } from "lucide-react"; +import type { ReactNode } from "react"; + +// A pill-shaped on/off toggle button for filters (cuisines, styles, appliances…). +// Includes a visible Check icon when pressed so state never relies on color alone. + +interface ChipProps { + pressed: boolean; + onClick: () => void; + children: ReactNode; + /** "sm" for dense filter rows, "md" for settings panels. */ + size?: "sm" | "md"; + /** Dashed outline when off: tells a second filter row apart from the first. */ + dashed?: boolean; +} + +const SIZES = { + sm: "min-h-8 px-2.5 py-1 text-xs gap-1.5", + md: "min-h-9 px-3.5 py-1.5 text-sm gap-1.5", +}; + +export function Chip({ pressed, onClick, children, size = "md", dashed = false }: ChipProps) { + const look = pressed + ? "border-selected-line bg-selected font-medium text-ink shadow-2xs" + : `border-line-strong bg-surface text-ink-muted hover:border-ink-muted hover:bg-raised hover:text-ink ${dashed ? "border-dashed" : ""}`; + + return ( + + ); +} diff --git a/app/components/ConfigPanel.tsx b/app/components/ConfigPanel.tsx new file mode 100644 index 0000000..4a2d32f --- /dev/null +++ b/app/components/ConfigPanel.tsx @@ -0,0 +1,272 @@ +"use client"; + +import { ChevronDown, RotateCcw, Shuffle, SlidersHorizontal, Wand2 } from "lucide-react"; +import { useState, type Dispatch } from "react"; +import { APPLIANCES, CUISINES, STYLES, activeRecipeIds, mealLabel, visibleMeals } from "@/app/lib/plan"; +import type { PlanAction } from "@/app/lib/usePlanState"; +import type { FillMode, MealPlanState } from "@/app/types"; +import { Chip } from "./Chip"; +import { ApplianceIcon, StyleIcon } from "./Icons"; +import { RadioGroup } from "./RadioGroup"; + +interface ConfigPanelProps { + state: MealPlanState; + dispatch: Dispatch; + onGenerate: (mode: FillMode) => void; + onClear: () => void; + isGenerating: boolean; +} + +const MEALS_OPTIONS = [ + { value: 1, label: "1" }, + { value: 2, label: "2" }, + { value: 3, label: "3" }, +] as const; + +const PEOPLE_OPTIONS = [ + { value: 1, label: "1" }, + { value: 2, label: "2" }, + { value: 3, label: "3" }, + { value: 4, label: "4" }, + { value: 5, label: "5" }, + { value: 6, label: "6" }, +] as const; + +export function ConfigPanel({ state, dispatch, onGenerate, onClear, isGenerating }: ConfigPanelProps) { + const [filtersOpen, setFiltersOpen] = useState(false); + + const filledCount = activeRecipeIds(state).length; + const mealNames = visibleMeals(state.mealsPerDay).map(mealLabel).join(" · "); + const activeFilterCount = + (APPLIANCES.length - state.selectedAppliances.length > 0 ? 1 : 0) + + state.preferredCuisines.length + + state.preferredStyles.length; + + const filterSummary = [ + `${state.selectedAppliances.length}/${APPLIANCES.length} appliances`, + state.preferredCuisines.length > 0 ? `${state.preferredCuisines.length} cuisines` : "Any cuisine", + state.preferredStyles.length > 0 ? `${state.preferredStyles.length} styles` : null, + ] + .filter(Boolean) + .join(" · "); + + return ( +
+

+ Plan parameters +

+ + {/* --- Compact Top Strip: Core Week Parameters ---------------------- */} +
+ {/* Budget */} +
+
+ + At checkout +
+
+ dispatch({ type: "setBudget", value: Number(e.target.value) })} + className="min-w-0 flex-1" + aria-label="Weekly budget slider" + /> +
+ $ + dispatch({ type: "setBudget", value: Math.max(0, Number(e.target.value) || 0) })} + className="min-h-9 w-22 rounded-lg border border-line-strong py-1 pr-2.5 pl-6 text-right font-mono text-sm font-semibold tabular-nums" + /> +
+
+
+ + {/* Days */} +
+
+ + + {state.daysCount} {state.daysCount === 1 ? "day" : "days"} + +
+
+ dispatch({ type: "setDays", value: Number(e.target.value) })} + className="w-full" + /> +
+
+ + {/* Meals per day */} +
+
+ Meals / day + {mealNames} +
+
+ dispatch({ type: "setMealsPerDay", value })} + fullWidth + /> +
+
+ + {/* Household size */} +
+
+ Household + Scales amounts +
+
+ dispatch({ type: "setHouseholdSize", value })} + fullWidth + /> +
+
+
+ + {/* --- Action Toolbar + Progressive Disclosure Toggle --------------- */} +
+
+ + + + + +
+ + +
+ + {/* --- Collapsible Appliances, Cuisines & Styles -------------------- */} + {filtersOpen && ( +
+
+ + Appliances you own · recipes needing anything else stay hidden + +
+ {APPLIANCES.map((a) => ( + dispatch({ type: "toggleAppliance", value: a.id })} + > + + {a.label} + + ))} +
+
+ +
+ + Preferred cuisines · none selected = any + +
+ {CUISINES.map((c) => ( + dispatch({ type: "toggleCuisine", value: c.id })} + > + {c.label} + + ))} +
+
+ +
+ + Cooking style · matches any selected + +
+ {STYLES.map((s) => ( + dispatch({ type: "toggleStyle", value: s.id })} + > + + {s.label} + + ))} +
+
+
+ )} +
+ ); +} diff --git a/app/components/GroceryListDrawer.tsx b/app/components/GroceryListDrawer.tsx new file mode 100644 index 0000000..c85187d --- /dev/null +++ b/app/components/GroceryListDrawer.tsx @@ -0,0 +1,221 @@ +"use client"; + +import { Check, Copy, Repeat2, Square, SquareCheckBig, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { formatAmount, formatUsd } from "@/app/lib/units"; +import type { Aisle, GroceryLine, GrocerySummary, ItemId } from "@/app/types"; +import { BudgetMeter } from "./BudgetMeter"; + +interface PanelProps { + summary: GrocerySummary; + budget: number; + priceSourceLabel: string; + onToggleOnHand: (itemId: ItemId, onHand: boolean) => void; +} + +export function GrocerySummaryPanel({ summary, budget, priceSourceLabel, onToggleOnHand }: PanelProps) { + const [copied, setCopied] = useState(false); + const byAisle = groupByAisle(summary.lines); + const remaining = budget - summary.checkoutTotal; + + async function copyList() { + const text = summary.lines + .filter((l) => !l.onHand) + .map((l) => `☐ ${l.packages} × ${l.item.name} (${l.item.packageLabel})`) + .join("\n"); + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard can be blocked; list remains visible on screen. + } + } + + return ( +
+ {/* --- Tactile Market Receipt Header -------------------------------- */} +
+ + +
+ + = 0 ? "Left in budget" : "Over budget"} + value={formatUsd(Math.abs(remaining))} + tone={remaining >= 0 ? "text-status-ok" : "text-status-danger"} + /> + + +
+ + {summary.sharedItemCount > 0 && ( +

+ + {summary.sharedItemCount} store item{summary.sharedItemCount > 1 ? "s" : ""} shared across meals +

+ )} +
+ + {/* --- Shopping List Header ----------------------------------------- */} +
+
+

Shopping list

+

Tap items you already have on hand

+
+ {summary.lines.length > 0 && ( + + )} +
+ + {summary.lines.length === 0 ? ( +

+ Add meals to build your aisle-sorted grocery receipt. +

+ ) : ( +
+ {byAisle.map(([aisle, lines]) => ( +
+

+ {aisle} +

+
    + {lines.map((line) => ( + + ))} +
+
+ ))} +
+ )} + +

{priceSourceLabel}. Actual store prices vary by location.

+
+ ); +} + +function GroceryRow({ line, onToggleOnHand }: { line: GroceryLine; onToggleOnHand: PanelProps["onToggleOnHand"] }) { + const { item } = line; + return ( +
  • + +
  • + ); +} + +function Stat({ label, value, hint, tone = "text-ink" }: { label: string; value: string; hint?: string; tone?: string }) { + return ( +
    +
    + {label} +
    +
    {value}
    +
    + ); +} + +function groupByAisle(lines: GroceryLine[]): [Aisle, GroceryLine[]][] { + const groups = new Map(); + for (const line of lines) { + const group = groups.get(line.item.aisle) ?? []; + group.push(line); + groups.set(line.item.aisle, group); + } + return [...groups]; +} + +// --------------------------------------------------------------------------- +// GroceryListDrawer: Native mobile bottom sheet with focus trapping, +// scroll locking, and @starting-style spring transition. +// --------------------------------------------------------------------------- + +interface DrawerProps extends PanelProps { + open: boolean; + onClose: () => void; +} + +export function GroceryListDrawer({ open, onClose, ...panelProps }: DrawerProps) { + const dialogRef = useRef(null); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + if (open && !dialog.open) dialog.showModal(); + if (!open && dialog.open) dialog.close(); + return () => { + if (dialog.open) dialog.close(); + }; + }, [open]); + + return ( + e.target === dialogRef.current && onClose()} + aria-labelledby="grocery-drawer-title" + className="sheet-dialog m-0 mt-auto max-h-[88dvh] w-full max-w-none overflow-y-auto rounded-t-2xl border-t border-line bg-surface p-0 shadow-2xl lg:hidden" + > +
    +
    +

    + Market receipt & list +

    + +
    + +
    +
    + ); +} diff --git a/app/components/Icons.tsx b/app/components/Icons.tsx new file mode 100644 index 0000000..b06a130 --- /dev/null +++ b/app/components/Icons.tsx @@ -0,0 +1,58 @@ +import type { LucideProps } from "lucide-react"; +import { + BookHeart, + CookingPot, + CupSoda, + Fish, + Flame, + Heater, + Leaf, + PackageCheck, + Salad, + Soup, + Timer, + Utensils, + Wind, + Zap, +} from "lucide-react"; +import type { Appliance, CookingStyle } from "@/app/types"; + +export function ApplianceIcon({ id, className = "size-4", ...props }: { id: Appliance } & LucideProps) { + const shared = { className, strokeWidth: 1.75, "aria-hidden": true as const, ...props }; + switch (id) { + case "stovetop": + return ; + case "oven": + return ; + case "air_fryer": + return ; + case "slow_cooker": + return ; + case "microwave": + return ; + case "blender": + return ; + case "rice_cooker": + return ; + } +} + +export function StyleIcon({ id, className = "size-3.5", ...props }: { id: CookingStyle } & LucideProps) { + const shared = { className, strokeWidth: 1.75, "aria-hidden": true as const, ...props }; + switch (id) { + case "caydens_corner": + return ; + case "seafood": + return ; + case "quick": + return ; + case "vegetarian": + return ; + case "healthy": + return ; + case "meal_prep": + return ; + case "comfort": + return ; + } +} diff --git a/app/components/IngredientPanel.tsx b/app/components/IngredientPanel.tsx new file mode 100644 index 0000000..fb23dbc --- /dev/null +++ b/app/components/IngredientPanel.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { ChevronDown, Search, X } from "lucide-react"; +import { useMemo, useState, type Dispatch } from "react"; +import { CATALOG, CATALOG_BY_ID, PANTRY_STAPLE_IDS } from "@/app/lib/catalog"; +import { RECIPES } from "@/app/lib/recipes"; +import type { PlanAction } from "@/app/lib/usePlanState"; +import type { Aisle, ItemId, ItemPreference, MealPlanState, StoreItem } from "@/app/types"; +import { RadioGroup, type RadioOption } from "./RadioGroup"; + +interface IngredientPanelProps { + state: MealPlanState; + dispatch: Dispatch; +} + +type Filter = "all" | "avoid" | "have"; + +const RECIPE_COUNT: ReadonlyMap = (() => { + const counts = new Map(); + for (const recipe of RECIPES) { + for (const id of new Set(recipe.ingredients.map((i) => i.itemId))) counts.set(id, (counts.get(id) ?? 0) + 1); + } + return counts; +})(); + +const FILTER_OPTIONS: readonly RadioOption[] = [ + { value: "all", label: "All" }, + { value: "avoid", label: "Avoided" }, + { value: "have", label: "On hand" }, +]; + +const PREF_OPTIONS: readonly RadioOption[] = [ + { value: "avoid", label: "Avoid", activeClass: "border border-status-danger-line bg-status-danger-bg text-status-danger font-semibold" }, + { value: "use", label: "Use", activeClass: "border border-selected-line/60 bg-surface text-ink font-semibold shadow-2xs" }, + { value: "have", label: "Have it", activeClass: "border border-status-ok-line bg-status-ok-bg text-status-ok font-semibold" }, +]; + +export function IngredientPanel({ state, dispatch }: IngredientPanelProps) { + const [query, setQuery] = useState(""); + const [filter, setFilter] = useState("all"); + + const preferenceOf = (id: ItemId): ItemPreference => + state.avoidedItems.includes(id) ? "avoid" : state.onHandItems.includes(id) ? "have" : "use"; + + const setPreference = (itemIds: ItemId[], preference: ItemPreference) => + dispatch({ type: "setItemPreference", itemIds, preference }); + + const needle = query.trim().toLowerCase(); + const groups = useMemo(() => { + const visible = CATALOG.filter((item) => { + if (needle && !item.name.toLowerCase().includes(needle)) return false; + if (filter === "avoid") return state.avoidedItems.includes(item.id); + if (filter === "have") return state.onHandItems.includes(item.id); + return true; + }); + const byAisle = new Map(); + for (const item of [...visible].sort((a, b) => a.name.localeCompare(b.name))) { + byAisle.set(item.aisle, [...(byAisle.get(item.aisle) ?? []), item]); + } + return [...byAisle].sort(([a], [b]) => a.localeCompare(b)); + }, [needle, filter, state.avoidedItems, state.onHandItems]); + + const staplesAllOnHand = PANTRY_STAPLE_IDS.every((id) => state.onHandItems.includes(id)); + + return ( +
    +
    + +
    +

    + Pantry & ingredient exclusions +

    +

    + {state.onHandItems.length} on hand ($0) ·{" "} + {state.avoidedItems.length} avoided +

    +
    + + + +
    + +
    + {state.avoidedItems.length > 0 && ( +
    + {state.avoidedItems.map((id) => ( + + ))} +
    + )} + + {/* Search + filter */} +
    +
    + + setQuery(e.target.value)} + className="min-h-10 w-full rounded-lg border border-line-strong py-1.5 pr-3 pl-9 text-sm" + aria-label="Search store items" + /> +
    + + +
    + +
    + + {(state.avoidedItems.length > 0 || state.onHandItems.length > 0) && ( + + )} +
    + +
    + {groups.length === 0 &&

    No store items match your search.

    } + + {groups.map(([aisle, items]) => ( +
    +
    +

    {aisle}

    + i.id)} avoidedItems={state.avoidedItems} onSet={setPreference} /> +
    +
      + {items.map((item) => { + const current = preferenceOf(item.id); + const count = RECIPE_COUNT.get(item.id) ?? 0; + return ( +
    • +
      +

      + {item.name} +

      +

      + Used in {count} recipe{count === 1 ? "" : "s"} +

      +
      + setPreference([item.id], val)} + size="xs" + /> +
    • + ); + })} +
    +
    + ))} +
    +
    +
    +
    + ); +} + +function AisleBulkToggle({ + itemIds, + avoidedItems, + onSet, +}: { + itemIds: ItemId[]; + avoidedItems: ItemId[]; + onSet: (itemIds: ItemId[], preference: ItemPreference) => void; +}) { + const allAvoided = itemIds.every((id) => avoidedItems.includes(id)); + return ( + + ); +} diff --git a/app/components/MealGrid.tsx b/app/components/MealGrid.tsx new file mode 100644 index 0000000..7b1f7e7 --- /dev/null +++ b/app/components/MealGrid.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { AlertTriangle, ArrowLeftRight, Plus, X } from "lucide-react"; +import { useState, type Dispatch } from "react"; +import { APPLIANCES, DAY_NAMES, activeSlots, describeBlockers, isCookable, mealLabel, recipeBlockers, slotKey } from "@/app/lib/plan"; +import { RECIPES_BY_ID } from "@/app/lib/recipes"; +import { formatUsd } from "@/app/lib/units"; +import type { PlanAction } from "@/app/lib/usePlanState"; +import type { MealPlanState } from "@/app/types"; +import { ApplianceIcon } from "./Icons"; + +interface MealGridProps { + state: MealPlanState; + dispatch: Dispatch; + mealCosts: Map; + onOpenSlot: (key: string) => void; + onViewSlot: (key: string) => void; + onRemoveSlot: (key: string, recipeTitle: string) => void; +} + +const applianceLabel = (id: string) => APPLIANCES.find((a) => a.id === id)?.label ?? id; + +export function MealGrid({ state, dispatch, mealCosts, onOpenSlot, onViewSlot, onRemoveSlot }: MealGridProps) { + const [dragFrom, setDragFrom] = useState(null); + const [dragOver, setDragOver] = useState(null); + /** Mobile & keyboard slot-swap picker: holds the origin slot key currently choosing a destination. */ + const [moveSourceKey, setMoveSourceKey] = useState(null); + + const allSlots = activeSlots(state); + const days = Array.from({ length: state.daysCount }, (_, day) => allSlots.filter((s) => s.dayIndex === day)); + + return ( +
    +
    +

    + Weekly menu +

    +

    + Drag meals or use the swap icon to rearrange · + Tap any planned dish for its scaled recipe +

    +
    + +
    + {days.map((slots, day) => { + const dayCost = slots.reduce((sum, s) => sum + (s.recipeId ? (mealCosts.get(s.recipeId) ?? 0) : 0), 0); + const hasAnyMeal = slots.some((s) => s.recipeId !== null); + + return ( +
    + {/* Architectural day header */} +
    +

    {DAY_NAMES[day]}

    + {hasAnyMeal && ( + {formatUsd(dayCost)} eaten + )} +
    + +
      + {slots.map((slot) => { + const key = slotKey(slot.dayIndex, slot.mealNumber); + const recipe = slot.recipeId ? RECIPES_BY_ID.get(slot.recipeId) : undefined; + const blocked = recipe ? !isCookable(recipe, state) : false; + const label = mealLabel(slot.mealNumber); + const isDropTarget = dragOver === key && dragFrom !== key; + const isMovingThis = moveSourceKey === key; + + return ( +
    • { + if (!dragFrom) return; + e.preventDefault(); + setDragOver(key); + }} + onDragLeave={() => setDragOver((current) => (current === key ? null : current))} + onDrop={(e) => { + e.preventDefault(); + if (dragFrom && dragFrom !== key) dispatch({ type: "swapSlots", from: dragFrom, to: key }); + setDragFrom(null); + setDragOver(null); + }} + className={`transition-colors ${isDropTarget ? "bg-brand-950 ring-2 ring-inset ring-brand-600" : ""}`} + > + {recipe ? ( +
      setDragFrom(key)} + onDragEnd={() => { + setDragFrom(null); + setDragOver(null); + }} + className={`p-3.5 transition-colors ${ + blocked ? "bg-status-warn-bg/65" : "hover:bg-raised/45" + }`} + > +
      + + + {/* Non-overlapping 36x36px touch & keyboard action controls */} +
      + {allSlots.length > 1 && ( + + )} + +
      +
      + + {/* Touch & keyboard accessible Swap/Move picker */} + {isMovingThis && ( +
      +

      Swap or move to another slot:

      +
      + {allSlots + .filter((target) => slotKey(target.dayIndex, target.mealNumber) !== key) + .map((target) => { + const targetKey = slotKey(target.dayIndex, target.mealNumber); + const occupant = target.recipeId ? RECIPES_BY_ID.get(target.recipeId)?.title : null; + return ( + + ); + })} +
      +
      + )} +
      + ) : ( + + )} +
    • + ); + })} +
    +
    + ); + })} +
    +
    + ); +} diff --git a/app/components/MealSelectorModal.tsx b/app/components/MealSelectorModal.tsx new file mode 100644 index 0000000..a041960 --- /dev/null +++ b/app/components/MealSelectorModal.tsx @@ -0,0 +1,297 @@ +"use client"; + +import { ChevronDown, ChevronUp, Repeat2, Search, SlidersHorizontal, X } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Cart } from "@/app/lib/grocery"; +import { CUISINES, DAY_NAMES, STYLES, activeRecipeIds, describeBlockers, mealKindFor, mealLabel, recipeBlockers, toggle } from "@/app/lib/plan"; +import { RECIPES, RECIPES_BY_ID } from "@/app/lib/recipes"; +import { formatUsd } from "@/app/lib/units"; +import type { CookingStyle, Cuisine, MealPlanState, Recipe } from "@/app/types"; +import { Chip } from "./Chip"; +import { StyleIcon } from "./Icons"; +import { RecipeDetail } from "./RecipeDetail"; + +interface MealSelectorModalProps { + slotKey: string | null; + state: MealPlanState; + onPick: (recipeId: string) => void; + onClose: () => void; +} + +export function MealSelectorModal({ slotKey, state, onPick, onClose }: MealSelectorModalProps) { + const dialogRef = useRef(null); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + if (slotKey && !dialog.open) dialog.showModal(); + if (!slotKey && dialog.open) dialog.close(); + return () => { + if (dialog.open) dialog.close(); + }; + }, [slotKey]); + + return ( + e.target === dialogRef.current && onClose()} + aria-labelledby="selector-title" + className="m-0 mt-auto max-h-[90dvh] w-full max-w-none overflow-hidden rounded-t-2xl border border-line bg-surface p-0 shadow-2xl sm:m-auto sm:max-w-2xl sm:rounded-2xl" + > + {slotKey && } + + ); +} + +interface Candidate { + recipe: Recipe; + addedCost: number; + reusedCount: number; + blockedReason: string; +} + +function SelectorContent({ + slotKey, + state, + onPick, + onClose, +}: { + slotKey: string; + state: MealPlanState; + onPick: (id: string) => void; + onClose: () => void; +}) { + const [dayIndex, mealNumber] = slotKey.split("-").map(Number); + const slotKind = mealKindFor(mealNumber); + const currentId = state.slots[slotKey]; + + const [query, setQuery] = useState(""); + const [filtersOpen, setFiltersOpen] = useState(false); + const [cuisines, setCuisines] = useState(state.preferredCuisines); + const [styles, setStyles] = useState(state.preferredStyles); + const [showAllKinds, setShowAllKinds] = useState(false); + const [showIncompatible, setShowIncompatible] = useState(false); + const [expandedId, setExpandedId] = useState(null); + + const candidates = useMemo(() => { + const others = activeRecipeIds(state); + if (currentId) others.splice(others.indexOf(currentId), 1); + + const cart = new Cart({ householdSize: state.householdSize, onHandItems: state.onHandItems }, others); + const itemsInCart = new Set(others.flatMap((id) => RECIPES_BY_ID.get(id)?.ingredients.map((i) => i.itemId) ?? [])); + + return RECIPES.map((recipe) => ({ + recipe, + addedCost: cart.marginal(recipe.id).checkout, + reusedCount: recipe.ingredients.filter((i) => itemsInCart.has(i.itemId)).length, + blockedReason: describeBlockers(recipeBlockers(recipe, state)), + })).sort((a, b) => a.addedCost - b.addedCost); + }, [state, currentId]); + + const needle = query.trim().toLowerCase(); + const visible = candidates.filter( + ({ recipe, blockedReason }) => + (showAllKinds || recipe.kind === slotKind) && + (showIncompatible || blockedReason === "") && + (cuisines.length === 0 || cuisines.includes(recipe.cuisine)) && + (styles.length === 0 || recipe.styles.some((s) => styles.includes(s))) && + (needle === "" || recipe.title.toLowerCase().includes(needle)), + ); + + const activeFilterCount = cuisines.length + styles.length + (showAllKinds ? 1 : 0) + (showIncompatible ? 1 : 0); + + return ( +
    + {/* Compact sticky header so 80%+ of mobile viewport shows recipes */} +
    +
    +
    +

    + {DAY_NAMES[dayIndex]} · {mealLabel(mealNumber)} +

    +

    + Choose a dish +

    +

    Sorted by marginal cost added to your current grocery cart

    +
    + +
    + +
    +
    + + setQuery(e.target.value)} + className="min-h-10 w-full rounded-lg border border-line-strong py-2 pr-3 pl-9 text-sm" + /> +
    + + +
    + + {filtersOpen && ( +
    +
    + {CUISINES.map((c) => ( + setCuisines((list) => toggle(list, c.id))}> + {c.label} + + ))} +
    + +
    + {STYLES.map((st) => ( + setStyles((list) => toggle(list, st.id))}> + + {st.label} + + ))} +
    + +
    + + +
    +
    + )} +
    + + {/* Results */} +
      + {visible.length === 0 && ( +
    • No recipes match these filters.
    • + )} + + {visible.map(({ recipe, addedCost, reusedCount, blockedReason }) => { + const disabled = blockedReason !== ""; + const expanded = expandedId === recipe.id; + return ( +
    • +
      + + +
      +
      +

      + +{formatUsd(Math.max(0, addedCost))} +

      +

      {addedCost < 0.01 ? "in cart" : "to cart"}

      +
      + +
      +
      + + {expanded && ( +
      + + {!disabled && ( + + )} +
      + )} +
    • + ); + })} +
    +
    + ); +} diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx new file mode 100644 index 0000000..ec365ec --- /dev/null +++ b/app/components/Navbar.tsx @@ -0,0 +1,36 @@ +import type { SyncStatus } from "@/app/lib/usePlanState"; +import { BudgetMeter } from "./BudgetMeter"; +import { SiteHeader } from "./SiteHeader"; + +interface NavbarProps { + checkoutTotal: number; + budget: number; + syncStatus: SyncStatus; + isGenerating: boolean; +} + +const SYNC_LABEL: Record = { + saved: { text: "Saved on device", dot: "bg-status-ok" }, + error: { text: "Storage blocked", dot: "bg-status-danger" }, +}; + +export function Navbar({ checkoutTotal, budget, syncStatus, isGenerating }: NavbarProps) { + const sync = isGenerating ? { text: "Building plan…", dot: "bg-status-warn animate-pulse" } : SYNC_LABEL[syncStatus]; + + return ( + + + {sync.text} + + } + rightSlot={ +
    + +
    + } + /> + ); +} diff --git a/app/components/OverlapExplorer.tsx b/app/components/OverlapExplorer.tsx new file mode 100644 index 0000000..f730ed4 --- /dev/null +++ b/app/components/OverlapExplorer.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { Check, Plus, Repeat2 } from "lucide-react"; +import { useState } from "react"; + +export interface OverlapDish { + id: string; + title: string; + cuisine: string; + standaloneCheckout: number; +} + +export interface OverlapComboSnapshot { + checkoutTotal: number; + usedTotal: number; + carryoverValue: number; + sharedNames: string[]; + packageCount: number; +} + +interface OverlapExplorerProps { + dishes: OverlapDish[]; + /** Keyed by sorted comma-joined dish IDs; precomputed on the server so zero catalog data ships to the landing bundle. */ + combos: Record; +} + +const fmt = (n: number) => `$${n.toFixed(2)}`; + +export function OverlapExplorer({ dishes, combos }: OverlapExplorerProps) { + const [selected, setSelected] = useState(() => dishes.slice(0, 2).map((d) => d.id)); + + function toggleDish(id: string) { + setSelected((prev) => { + if (prev.includes(id)) { + return prev.length > 1 ? prev.filter((x) => x !== id) : prev; // keep at least 1 selected + } + return [...prev, id]; + }); + } + + const comboKey = [...selected].sort().join(","); + const current = combos[comboKey]; + const naiveSum = dishes.filter((d) => selected.includes(d.id)).reduce((acc, d) => acc + d.standaloneCheckout, 0); + const saved = Math.max(0, naiveSum - (current?.checkoutTotal ?? 0)); + + return ( +
    +
    +

    Interactive checkout math · 2 people

    +

    + Toggle dishes to watch shared packages drop the bill +

    +

    + Grocery stores sell whole bags of onions, rice, and spices—not single tablespoons. Selecting meals that reuse open + packages slashes what each extra dinner adds at the register. +

    + +
      + {dishes.map((dish) => { + const active = selected.includes(dish.id); + return ( +
    • + +
    • + ); + })} +
    +
    + + {/* Simulated paper receipt */} +
    +
    +
    + Checkout Receipt + {selected.length} {selected.length === 1 ? "meal" : "meals"} +
    + +
    +
    +
    Buying each meal separately
    +
    {fmt(naiveSum)}
    +
    + {saved > 0.01 && ( +
    +
    Shared-package savings
    +
    −{fmt(saved)}
    +
    + )} +
    +
    Combined register total
    +
    {fmt(current?.checkoutTotal ?? 0)}
    +
    +
    + +
    +
    +
    Food eaten
    +
    {fmt(current?.usedTotal ?? 0)}
    +
    +
    +
    Kept in pantry
    +
    {fmt(current?.carryoverValue ?? 0)}
    +
    +
    +
    + +
    + {current && current.sharedNames.length > 0 ? ( +

    + + + Reused across meals: {current.sharedNames.slice(0, 5).join(", ")} + {current.sharedNames.length > 5 ? ` +${current.sharedNames.length - 5} more` : ""} + +

    + ) : ( +

    Select a second dish to see which store packages overlap in your cart.

    + )} +
    +
    +
    + ); +} diff --git a/app/components/PlannerDashboard.tsx b/app/components/PlannerDashboard.tsx new file mode 100644 index 0000000..8563910 --- /dev/null +++ b/app/components/PlannerDashboard.tsx @@ -0,0 +1,246 @@ +"use client"; + +import { AlertCircle, RotateCcw, ShoppingBag, X } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState, useTransition } from "react"; +import { generateMealPlanAction } from "@/app/actions"; +import { Cart, buildGroceryList } from "@/app/lib/grocery"; +import { activeRecipeIds } from "@/app/lib/plan"; +import { averageGroceryProvider } from "@/app/lib/pricing"; +import { RECIPES } from "@/app/lib/recipes"; +import { usePlanState } from "@/app/lib/usePlanState"; +import type { FillMode } from "@/app/types"; +import { BudgetMeter } from "./BudgetMeter"; +import { ConfigPanel } from "./ConfigPanel"; +import { GroceryListDrawer, GrocerySummaryPanel } from "./GroceryListDrawer"; +import { IngredientPanel } from "./IngredientPanel"; +import { MealGrid } from "./MealGrid"; +import { MealSelectorModal } from "./MealSelectorModal"; +import { Navbar } from "./Navbar"; +import { RecipeDialog } from "./RecipeDialog"; + +interface UndoSnapshot { + message: string; + previousSlots: Record; +} + +export default function PlannerDashboard() { + const { state, dispatch, syncStatus } = usePlanState(); + const [isGenerating, startGenerating] = useTransition(); + const [notes, setNotes] = useState([]); + const [openSlot, setOpenSlot] = useState(null); + const [viewSlot, setViewSlot] = useState(null); + const [groceriesOpen, setGroceriesOpen] = useState(false); + const [undo, setUndo] = useState(null); + + // Auto-dismiss undo toast after 6 seconds + useEffect(() => { + if (!undo) return; + const timer = window.setTimeout(() => setUndo(null), 6000); + return () => window.clearTimeout(timer); + }, [undo]); + + const summary = useMemo( + () => buildGroceryList(activeRecipeIds(state), { householdSize: state.householdSize, onHandItems: state.onHandItems }), + [state], + ); + + const mealCosts = useMemo(() => { + const emptyCart = new Cart({ householdSize: state.householdSize, onHandItems: state.onHandItems }); + return new Map(RECIPES.map((r) => [r.id, emptyCart.marginal(r.id).eaten])); + }, [state.householdSize, state.onHandItems]); + + function handleGenerate(fillMode: FillMode) { + startGenerating(async () => { + try { + const result = await generateMealPlanAction({ state, fillMode, seed: Date.now() }); + dispatch({ type: "replaceSlots", slots: result.slots }); + setNotes(result.notes); + setUndo(null); + } catch { + setNotes(["Couldn't reach the planner. Check your connection and try again."]); + } + }); + } + + const handleRemoveSlot = useCallback( + (key: string, recipeTitle: string) => { + setUndo({ message: `Removed "${recipeTitle}"`, previousSlots: { ...state.slots } }); + dispatch({ type: "setSlot", key, recipeId: null }); + }, + [dispatch, state.slots], + ); + + const handleClearWeek = useCallback(() => { + const count = activeRecipeIds(state).length; + if (count === 0) return; + setUndo({ message: `Cleared ${count} planned meal${count === 1 ? "" : "s"}`, previousSlots: { ...state.slots } }); + dispatch({ type: "clearSlots" }); + }, [dispatch, state]); + + const handleUndo = useCallback(() => { + if (!undo) return; + dispatch({ type: "replaceSlots", slots: undo.previousSlots }); + setUndo(null); + }, [dispatch, undo]); + + const closeSelector = useCallback(() => setOpenSlot(null), []); + const closeRecipe = useCallback(() => setViewSlot(null), []); + const closeGroceries = useCallback(() => setGroceriesOpen(false), []); + const toggleOnHand = useCallback( + (itemId: string, onHand: boolean) => dispatch({ type: "setItemPreference", itemIds: [itemId], preference: onHand ? "have" : "use" }), + [dispatch], + ); + + const mealCount = activeRecipeIds(state).length; + + return ( + <> + + +
    +
    + {/* Left: compact control bar -> immediate MealGrid -> Pantry below */} +
    + + + {notes.length > 0 && ( +
    +
    + +
      + {notes.map((note) => ( +
    • {note}
    • + ))} +
    +
    + +
    + )} + +
    + +
    + + +
    + + {/* Right: persistent Market Receipt summary (desktop) */} + +
    +
    + + {/* Undo Toast for accidental meal removal or Clear */} + {undo && ( +
    + {undo.message} + + +
    + )} + + {/* Mobile: sticky bottom bar */} +
    +
    +
    + +
    + +
    +

    + {mealCount} meals planned, estimated checkout {summary.checkoutTotal.toFixed(2)} dollars +

    +
    + + + + { + setViewSlot(null); + setOpenSlot(key); + }} + onRemove={(key, title) => { + handleRemoveSlot(key, title); + setViewSlot(null); + }} + /> + + { + if (openSlot) dispatch({ type: "setSlot", key: openSlot, recipeId }); + setOpenSlot(null); + }} + /> + + ); +} diff --git a/app/components/PlannerLoader.tsx b/app/components/PlannerLoader.tsx new file mode 100644 index 0000000..818bac1 --- /dev/null +++ b/app/components/PlannerLoader.tsx @@ -0,0 +1,31 @@ +"use client"; + +import dynamic from "next/dynamic"; + +const PlannerDashboard = dynamic(() => import("./PlannerDashboard"), { + ssr: false, + loading: () => , +}); + +export function PlannerLoader() { + return ; +} + +function DashboardSkeleton() { + return ( +
    +
    +
    +
    +
    +
    + {Array.from({ length: 4 }, (_, i) => ( +
    + ))} +
    +
    +
    +
    +
    + ); +} diff --git a/app/components/RadioGroup.tsx b/app/components/RadioGroup.tsx new file mode 100644 index 0000000..61d86c3 --- /dev/null +++ b/app/components/RadioGroup.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useRef, type KeyboardEvent, type ReactNode } from "react"; + +export interface RadioOption { + value: T; + label: ReactNode; + activeClass?: string; +} + +interface RadioGroupProps { + name: string; + options: readonly RadioOption[]; + value: T; + onChange: (value: T) => void; + size?: "xs" | "sm"; + fullWidth?: boolean; +} + +/** + * Accessible segmented control implementing WAI-ARIA `radiogroup` roving tabIndex + * and arrow/Home/End keyboard navigation (1 Tab stop per group instead of N). + */ +export function RadioGroup({ + name, + options, + value, + onChange, + size = "sm", + fullWidth = false, +}: RadioGroupProps) { + const buttonsRef = useRef<(HTMLButtonElement | null)[]>([]); + + function handleKeyDown(e: KeyboardEvent) { + const currentIndex = Math.max(0, options.findIndex((o) => o.value === value)); + let nextIndex = -1; + + if (e.key === "ArrowRight" || e.key === "ArrowDown") { + nextIndex = (currentIndex + 1) % options.length; + } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { + nextIndex = (currentIndex - 1 + options.length) % options.length; + } else if (e.key === "Home") { + nextIndex = 0; + } else if (e.key === "End") { + nextIndex = options.length - 1; + } + + if (nextIndex !== -1) { + e.preventDefault(); + onChange(options[nextIndex].value); + buttonsRef.current[nextIndex]?.focus(); + } + } + + const pad = + size === "xs" + ? fullWidth + ? "min-h-8 flex-1 min-w-0 px-1.5 py-1 text-xs" + : "min-h-8 px-2.5 py-1 text-xs" + : fullWidth + ? "min-h-9 flex-1 min-w-0 px-2 py-1.5 text-sm" + : "min-h-9 min-w-9 px-3 py-1.5 text-sm"; + + return ( +
    + {options.map((option, i) => { + const checked = option.value === value; + const activeStyle = option.activeClass ?? "bg-surface text-ink border border-selected-line/60 shadow-2xs"; + return ( + + ); + })} +
    + ); +} diff --git a/app/components/RecipeBrowser.tsx b/app/components/RecipeBrowser.tsx new file mode 100644 index 0000000..64fb60a --- /dev/null +++ b/app/components/RecipeBrowser.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { BookHeart, Search, SlidersHorizontal } from "lucide-react"; +import Link from "next/link"; +import { ViewTransition, useMemo, useState, type ReactNode } from "react"; +import { APPLIANCES, CUISINES, MEAL_KIND_LABELS, STYLES, toggle } from "@/app/lib/plan"; +import type { Appliance, CookingStyle, Cuisine, MealKind } from "@/app/types"; +import { Chip } from "./Chip"; +import { ApplianceIcon, StyleIcon } from "./Icons"; + +export interface RecipeSummary { + id: string; + title: string; + description: string; + cuisine: Cuisine; + kind: MealKind; + styles: CookingStyle[]; + appliances: Appliance[]; + prepTime: number; + ingredientText: string; +} + +type KindFilter = "all" | MealKind; +type SortOrder = "az" | "quickest"; + +export function RecipeBrowser({ recipes }: { recipes: RecipeSummary[] }) { + const [query, setQuery] = useState(""); + const [kind, setKind] = useState("all"); + const [cuisines, setCuisines] = useState([]); + const [styles, setStyles] = useState([]); + const [kitchen, setKitchen] = useState([]); + const [sort, setSort] = useState("az"); + const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false); + + const visible = useMemo(() => { + const needle = query.trim().toLowerCase(); + return recipes + .filter((r) => kind === "all" || r.kind === kind) + .filter((r) => cuisines.length === 0 || cuisines.includes(r.cuisine)) + .filter((r) => styles.length === 0 || r.styles.some((s) => styles.includes(s))) + .filter((r) => kitchen.length === 0 || r.appliances.every((a) => kitchen.includes(a))) + .filter((r) => !needle || r.title.toLowerCase().includes(needle) || r.ingredientText.includes(needle)) + .sort((a, b) => (sort === "quickest" ? a.prepTime - b.prepTime : 0) || a.title.localeCompare(b.title)); + }, [recipes, query, kind, cuisines, styles, kitchen, sort]); + + const activeChipCount = (kind !== "all" ? 1 : 0) + cuisines.length + styles.length + kitchen.length; + const filtersActive = Boolean(query) || activeChipCount > 0; + + const clearAll = () => { + setQuery(""); + setKind("all"); + setCuisines([]); + setStyles([]); + setKitchen([]); + }; + + return ( +
    + {/* --- Search + Collapsible Mobile Filters / Sticky Desktop Sidebar --- */} + + + {/* --- Results -------------------------------------------------------- */} +
    +
    +

    + Showing {visible.length} of{" "} + {recipes.length} recipes + {filtersActive && ( + + )} +

    + + +
    + + {visible.length === 0 ? ( +

    + No recipes match these filters. Try relaxing an appliance or cuisine filter. +

    + ) : ( +
      + {visible.map((r) => { + const applianceNames = r.appliances.map((a) => APPLIANCES.find((x) => x.id === a)?.label).join(", ") || "No cooking"; + return ( +
    • + + + {r.styles.includes("caydens_corner") && ( + + )} + + {CUISINES.find((c) => c.id === r.cuisine)?.label} · {MEAL_KIND_LABELS[r.kind]} + + + + + + {r.title} + + + + {r.description} + + + {r.prepTime} min + + {r.appliances.length > 0 ? ( + <> + {r.appliances.map((a) => ( + + ))} + Requires {applianceNames} + + ) : ( + No cooking + )} + + + +
    • + ); + })} +
    + )} +
    +
    + ); +} + +function FilterGroup({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { + return ( +
    + + {label} {hint && · {hint}} + +
    {children}
    +
    + ); +} diff --git a/app/components/RecipeDetail.tsx b/app/components/RecipeDetail.tsx new file mode 100644 index 0000000..0cde229 --- /dev/null +++ b/app/components/RecipeDetail.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { Check, Lightbulb } from "lucide-react"; +import { useState } from "react"; +import { ingredientLines } from "@/app/lib/display"; +import { APPLIANCES, CUISINES, MEAL_KIND_LABELS, STYLES } from "@/app/lib/plan"; +import type { Recipe } from "@/app/types"; +import { ApplianceIcon, StyleIcon } from "./Icons"; + +interface RecipeDetailProps { + recipe: Recipe; + /** People to scale ingredient amounts for. */ + servings: number; + /** Hide the cuisine/time/appliance chips when surrounding UI already shows them. */ + showMeta?: boolean; + /** Smaller type for use inside lists. */ + compact?: boolean; + /** Allow tapping ingredients & steps to check them off while cooking. */ + interactive?: boolean; +} + +export function RecipeDetail({ + recipe, + servings, + showMeta = true, + compact = false, + interactive = false, +}: RecipeDetailProps) { + const lines = ingredientLines(recipe, servings); + const text = compact ? "text-sm" : "text-base"; + const [checkedIngredients, setCheckedIngredients] = useState>(() => new Set()); + const [checkedSteps, setCheckedSteps] = useState>(() => new Set()); + + function toggleSet(setter: React.Dispatch>>, index: number) { + setter((prev) => { + const next = new Set(prev); + if (next.has(index)) next.delete(index); + else next.add(index); + return next; + }); + } + + return ( +
    + {showMeta && } + +
    + {/* --- Ingredients ------------------------------------------------- */} +
    +
    +

    + Ingredients{" "} + + · {servings} serving{servings === 1 ? "" : "s"} + +

    + {interactive && checkedIngredients.size > 0 && ( + + )} +
    + +
      + {lines.map((line, i) => { + const done = interactive && checkedIngredients.has(i); + const content = ( + <> + + {line.quantity} {line.unit} + + + {line.name} + {line.detail && ({line.detail})} + + + ); + + return ( +
    • + {interactive ? ( + + ) : ( +
      {content}
      + )} +
    • + ); + })} +
    +
    + + {/* --- Instructions ------------------------------------------------ */} +
    +
    +

    Instructions

    + {interactive && checkedSteps.size > 0 && ( + + )} +
    + + {servings !== recipe.servings && ( +

    + Ingredient quantities on the left are scaled to {servings}. + Step prose is written for the original {recipe.servings}-serving batch—scale pan sizes or liquid mentions the same way. +

    + )} + +
      + {recipe.steps.map((step, i) => { + const done = interactive && checkedSteps.has(i); + const badge = ( + + {done ? : i + 1} + + ); + + return ( +
    1. + {interactive ? ( + + ) : ( +
      + {badge} + {step} +
      + )} +
    2. + ); + })} +
    + + {recipe.tip && ( +
    + +

    + Cook's note: {recipe.tip} +

    +
    + )} +
    +
    +
    + ); +} + +export function RecipeMeta({ recipe }: { recipe: Recipe }) { + const cuisineLabel = CUISINES.find((c) => c.id === recipe.cuisine)?.label; + + return ( +
      + {cuisineLabel && ( +
    • + {cuisineLabel} +
    • + )} +
    • + {MEAL_KIND_LABELS[recipe.kind]} +
    • +
    • + {recipe.prepTime} min +
    • +
    • + Serves {recipe.servings} +
    • + + {recipe.requiredAppliances.length === 0 ? ( +
    • No cooking
    • + ) : ( + recipe.requiredAppliances.map((a) => { + const label = APPLIANCES.find((x) => x.id === a)?.label ?? a; + return ( +
    • + + {label} +
    • + ); + }) + )} + + {recipe.styles.map((s) => { + const label = STYLES.find((x) => x.id === s)?.label; + if (!label) return null; + return ( +
    • + + {label} +
    • + ); + })} +
    + ); +} diff --git a/app/components/RecipeDialog.tsx b/app/components/RecipeDialog.tsx new file mode 100644 index 0000000..22e0ecf --- /dev/null +++ b/app/components/RecipeDialog.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { AlertTriangle, ArrowRight, ArrowRightLeft, Trash2, X } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useRef } from "react"; +import { DAY_NAMES, describeBlockers, isCookable, mealLabel, recipeBlockers } from "@/app/lib/plan"; +import { RECIPES_BY_ID } from "@/app/lib/recipes"; +import type { MealPlanState } from "@/app/types"; +import { RecipeDetail, RecipeMeta } from "./RecipeDetail"; + +interface RecipeDialogProps { + slotKey: string | null; + state: MealPlanState; + onClose: () => void; + onSwap: (slotKey: string) => void; + onRemove: (slotKey: string, recipeTitle: string) => void; +} + +export function RecipeDialog({ slotKey, state, onClose, onSwap, onRemove }: RecipeDialogProps) { + const dialogRef = useRef(null); + const recipe = slotKey ? RECIPES_BY_ID.get(state.slots[slotKey]) : undefined; + const open = Boolean(slotKey && recipe); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + if (open && !dialog.open) dialog.showModal(); + if (!open && dialog.open) dialog.close(); + return () => { + if (dialog.open) dialog.close(); + }; + }, [open]); + + const [dayIndex, mealNumber] = (slotKey ?? "0-1").split("-").map(Number); + + return ( + e.target === dialogRef.current && onClose()} + aria-labelledby="recipe-dialog-title" + className="m-0 mt-auto max-h-[92dvh] w-full max-w-none overflow-y-auto rounded-t-2xl border border-line bg-surface p-0 text-ink shadow-2xl sm:m-auto sm:max-w-3xl sm:rounded-2xl" + > + {open && recipe && slotKey && ( +
    +
    +
    +

    + {DAY_NAMES[dayIndex]} · {mealLabel(mealNumber)} +

    +

    + {recipe.title} +

    +

    {recipe.description}

    +
    + +
    + +
    + +
    + + {!isCookable(recipe, state) && ( +

    + + {describeBlockers(recipeBlockers(recipe, state))}. Consider swapping this meal. +

    + )} + +
    + +
    + +
    + + + + Full recipe & cook mode + + +
    +
    + )} +
    + ); +} diff --git a/app/components/RecipeServings.tsx b/app/components/RecipeServings.tsx new file mode 100644 index 0000000..776aa75 --- /dev/null +++ b/app/components/RecipeServings.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { Flame, Minus, Plus } from "lucide-react"; +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import type { Recipe } from "@/app/types"; +import { RecipeDetail } from "./RecipeDetail"; + +const noopSubscribe = () => () => {}; + +/** RecipeDetail with an accessible servings stepper + Screen Wake Lock ("Cook mode") toggle. */ +export function RecipeServings({ recipe }: { recipe: Recipe }) { + const [servings, setServings] = useState(recipe.servings); + const [cookMode, setCookMode] = useState(false); + const wakeSupported = useSyncExternalStore(noopSubscribe, () => "wakeLock" in navigator, () => false); + const wakeLockRef = useRef(null); + + useEffect(() => { + if (!cookMode || !wakeSupported) { + wakeLockRef.current?.release().catch(() => {}); + wakeLockRef.current = null; + return; + } + + let cancelled = false; + async function acquire() { + try { + const sentinel = await navigator.wakeLock.request("screen"); + if (cancelled) { + await sentinel.release(); + return; + } + wakeLockRef.current = sentinel; + } catch { + // Battery saver or background tab can refuse wakeLock; fail gracefully. + } + } + + acquire(); + const onVisibility = () => { + if (document.visibilityState === "visible" && cookMode) acquire(); + }; + document.addEventListener("visibilitychange", onVisibility); + + return () => { + cancelled = true; + document.removeEventListener("visibilitychange", onVisibility); + wakeLockRef.current?.release().catch(() => {}); + wakeLockRef.current = null; + }; + }, [cookMode, wakeSupported]); + + return ( +
    +
    +
    + Scale servings +
    + + + {servings} + + +
    + Tap any ingredient or step below to check it off +
    + + {wakeSupported && ( + + )} +
    + + +
    + ); +} diff --git a/app/components/RotatingPreview.tsx b/app/components/RotatingPreview.tsx new file mode 100644 index 0000000..3be95a1 --- /dev/null +++ b/app/components/RotatingPreview.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { Pause, Play, Repeat2, ShoppingBag } from "lucide-react"; +import { useEffect, useState } from "react"; + +export interface PreviewSet { + theme: string; + blurb: string; + meals: { slot: string; title: string; cost: string }[]; + total: string; + sharedItems: number; + itemCount: number; +} + +const INTERVAL_MS = 5000; + +export function RotatingPreview({ sets }: { sets: PreviewSet[] }) { + const [index, setIndex] = useState(0); + const [paused, setPaused] = useState(false); + const [hovering, setHovering] = useState(false); + const running = !paused && !hovering; + + useEffect(() => { + if (!running || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + const timer = window.setInterval(() => setIndex((i) => (i + 1) % sets.length), INTERVAL_MS); + return () => window.clearInterval(timer); + }, [running, index, sets.length]); + + const set = sets[index]; + + return ( +
    setHovering(true)} + onMouseLeave={() => setHovering(false)} + onFocus={() => setHovering(true)} + onBlur={() => setHovering(false)} + > + {/* Architectural editorial frame — crisp 1px border & tactile offset shadow instead of blurred glow blobs */} +
    + {/* Top progress rule */} +
    +
    +
    + +
    +
    +
    +
    +

    Sample menu · 2 people

    +

    {set.theme}

    +
    + + {set.blurb} + +
    + +
      + {set.meals.map((m) => ( +
    • +
      +

      {m.slot}

      +

      {m.title}

      +
      +

      {m.cost}/meal

      +
    • + ))} +
    + + {/* Receipt tear-off footer */} +
    +
    + + + {set.sharedItems} shared store items + + + + {set.itemCount} pkgs · {set.total} + +
    +
    +
    + + {/* Accessible 40x40px pagination & playback controls */} +
    +
    + {sets.map((s, i) => ( + + ))} +
    + +
    +
    +
    +
    + ); +} diff --git a/app/components/SiteHeader.tsx b/app/components/SiteHeader.tsx new file mode 100644 index 0000000..0ddd464 --- /dev/null +++ b/app/components/SiteHeader.tsx @@ -0,0 +1,109 @@ +import { BookHeart, BookOpen, CalendarRange, UtensilsCrossed } from "lucide-react"; +import Link from "next/link"; +import type { ReactNode } from "react"; +import { ThemeToggle } from "./ThemeToggle"; + +export type ActiveNav = "home" | "recipes" | "caydens-corner" | "plan"; + +export function Logo({ small = false, subtitle }: { small?: boolean; subtitle?: ReactNode }) { + return ( +
    + + + +
    + + Plan my Meals + +
    + + by{" "} + + Cayden Codes + + + {subtitle && ( + <> + · + {subtitle} + + )} +
    +
    +
    + ); +} + +interface SiteHeaderProps { + active?: ActiveNav; + subtitle?: ReactNode; + rightSlot?: ReactNode; +} + +export function SiteHeader({ active = "home", subtitle, rightSlot }: SiteHeaderProps) { + const linkClass = (isCurrent: boolean) => + `trim-cap inline-flex min-h-10 items-center gap-1.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors sm:px-3 ${ + isCurrent ? "bg-selected text-ink" : "text-ink-muted hover:bg-raised hover:text-ink" + }`; + + return ( +
    +
    + + + + + {rightSlot} + + +
    +
    + ); +} diff --git a/app/components/ThemeToggle.tsx b/app/components/ThemeToggle.tsx new file mode 100644 index 0000000..7639a5c --- /dev/null +++ b/app/components/ThemeToggle.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { Moon, Sun } from "lucide-react"; +import { useSyncExternalStore } from "react"; + +const STORAGE_KEY = "plan-my-meals:theme"; +const EVENT_NAME = "plan-my-meals:theme-change"; + +type ResolvedTheme = "light" | "dark"; + +function getSnapshot(): ResolvedTheme { + const explicit = document.documentElement.dataset.theme; + if (explicit === "light" || explicit === "dark") return explicit; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} + +function subscribe(callback: () => void): () => void { + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + mq.addEventListener("change", callback); + window.addEventListener(EVENT_NAME, callback); + return () => { + mq.removeEventListener("change", callback); + window.removeEventListener(EVENT_NAME, callback); + }; +} + +export function ThemeToggle() { + // Server snapshot returns null so hydration never mismatches; button renders a stable shell first. + const theme = useSyncExternalStore(subscribe, getSnapshot, () => null); + + function toggleTheme() { + const next: ResolvedTheme = (theme ?? getSnapshot()) === "dark" ? "light" : "dark"; + document.documentElement.dataset.theme = next; + try { + window.localStorage.setItem(STORAGE_KEY, next); + } catch { + // Ignore blocked storage; dataset.theme still updates the active session. + } + window.dispatchEvent(new Event(EVENT_NAME)); + } + + return ( + + ); +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..af98450595e8b8efd9e505cddc5ed705b665a4d8 GIT binary patch literal 15086 zcmdU$XN*0PW5y#)gXL_h{3FkJ#X`oXpI?bB5#G)w~S7Qa+`W<1AB#X3C=?&UhvAO=|S+ zQpCR%R9`2h-z)7_x}fw(>6KDpfVd}*GI-!c2H9UDrKwS>=#NU@Ddj7@Q4*g7FEYrY z16}AW?v$zq+9+i!-B+T1{bG*9hmHqIUn`*-8^w`osM0?vVJi}rWb@IzKnYt#l~PpE zZq^t6O{HGFdQ!P^WzlxPHWGIHcd>D{}x={sTUg9&WW6602m zQnBH6>!yx}4jn2B7A%n2vuDesNt2{;dd zh7B@$^l0CFc@ZrPKrArsz@`@AQzbS#)CVarSbb!I8ty{PH zk}X}jv}9#v$)iV)Gb-#m>4qUO6)cT2PV3 zpI*Is$&Ww&XxZfC$&*s6RxM+zLWK&_s8J(BjQt6=fsGRsIez?@ix)2jvEROZTiUm8 zAGA}oYE|jjv7_)^<8Q|B8-jA4x_+h43HdwG`akQZ{rmR^u|IqEOoj~`7J{KpojS(< z>D#xjnZIoEL8L15HMT(z#WR80C=8eJ6m1l4LW@cu}s8OTj^Upsw@4?*W6SJES z?6iS9&)0pV`lp_$Z=3e*+h^9ZUK>rCG?5uIW|;4L`0(N0^@=YH0qjNg{kCh@PHx@0 zWueQ33l}2#^Q~LAHa;_LgYTK1p6(a3o6pAd+gv_56 zTeSG07MmF9|6)G^Q&#}`NMn_Um~~0isUI6)0h8ZU;`^Lf>f{vqND)`@P9?$9TK1fc;tCyQ6!+t!2dtUtke$_><;tw&q0@ECem?{wl!rS3|>b6OetkXT}@W?k_+rGrYh zl>Vi}m;6*0c#P>*l+@N$%>EBBFmQe{Fc#%OC9f_nmD)f~S=dohhj8vYl zv6`=x&=pVWsLX#+q8mig9W$TUxvt*}bzk7L-lNk`>Y+?Gm163T#zTyXvm=~)sb0N0 zaaweuJ6Zx`(zaEHG-%L3KK=Al6T>DZPhLQGq zy?Yqg@b-sP&7-d+WX~-}jDPy{>9TCuG9i8(A^wLgY}(XWZ-_f3WuJ2&?4j-5yH}Ph zSrTpU9$VNX@qMmWifEgC6yB+S|Ng&-_q*kY6P-PKR*oJ$D&4wui>NOkgH3F=P`Ve$ z3oFArk%&op&JP9oiCbR3eqHX}yJupw-hGKM`tJs8Gp0rz*J9ak*s!6TKY!j}B<@Qe z2xHHihfO=4d&@$equD1uh)vR!D_5jhvt}W(F>o+n#QfeP*=Ky4KYza26Uxue7xt`( zwGs1UFEoa2%CXJd6_q!V{lS9=%abQh(nAuU?8F#8U%^agA*f5vUU zO`A5Rzftd-H*XGp=j=}sgJvHomR>El^LJEU_xn@7oB=$3{5YoN^y$-PoVT`!UrwAj z(d>cRyeL=eJNUAXP0n?!TD8j9&dbY_&Ye4l$oA~n)9gP*+v9QJq3wP5KkQGLGDTj# zd};PI#*Q5uVjI0fh76HSn>LxVdoDY_;0u&tn{OaexyiTnfB*h{;fw|QLCcpf4;xpW zFkyme8_o^J(yQeMlzfjrrqcE2$dM!Fd_g$&SWBkEa^gaG!|Jt={ z=KFH#39++i(IT_Yx^(H%ko++GcKr1<|Ih~0rcI04uj!BQ`Z)V#=O2Y;=E9`s-!ShO zsSe!pFZPSf|L}>y>wkcu$o#Ju7{d8q*e4a4|Eapd)ji*PB>xlpq$2UZRgc#`wqyC< z*e8K8>Gg-|00V0nbj5maWeG|22l`3AlJ{NwJ|l|`ufMHXz7k^tYsg%g=)+?N8;M_9LtdS2KI3UcQZk+)cWFG|X4L^R?nKNgM zJp7#1#y{fx7Zcyn1ijH<*t+zGuIF{p8Z6OUCbF zJuqm{AfqFUdUSJ))2_z+s>OeFWelDY)Ji4$N2uccI^tDkzj4ezJD0s z6WNgVqu%*n4eJGq6%4dL{= 4.5:1 + - Interactive borders (`line-strong`, `selected-line`) >= 3.0:1 (SC 1.4.11) + --------------------------------------------------------------------------- */ +@theme { + /* Surfaces: warm unbleached linen in light mode, cast-iron slate in dark */ + --color-canvas: light-dark(oklch(97.4% 0.010 81.8), oklch(20.0% 0.021 245.9)); + --color-surface: light-dark(oklch(99.4% 0.006 84.6), oklch(24.5% 0.028 244.8)); + --color-raised: light-dark(oklch(94.3% 0.017 79.4), oklch(30.0% 0.035 242.7)); + --color-line: light-dark(oklch(84.6% 0.029 81.5), oklch(37.6% 0.042 242.8)); + --color-line-strong: light-dark(oklch(56.7% 0.032 74.0), oklch(62.5% 0.046 239.9)); + + /* Typography ink hierarchy */ + --color-ink: light-dark(oklch(22.8% 0.016 62.5), oklch(96.4% 0.021 79.1)); + --color-ink-muted: light-dark(oklch(40.7% 0.024 64.6), oklch(84.1% 0.017 250.9)); + --color-ink-faint: light-dark(oklch(48.0% 0.026 67.1), oklch(72.6% 0.028 251.7)); + + /* Primary culinary accent: Terracotta Paprika (light) / Warm Peach (dark) */ + --color-brand-300: light-dark(oklch(49.7% 0.151 37.0), oklch(82.9% 0.092 53.1)); + --color-brand-400: light-dark(oklch(51.8% 0.155 37.0), oklch(81.8% 0.098 52.8)); + --color-brand-500: light-dark(oklch(48.1% 0.146 36.8), oklch(85.8% 0.088 52.7)); + --color-brand-600: light-dark(oklch(54.0% 0.159 37.1), oklch(80.8% 0.102 52.6)); + --color-brand-950: light-dark(oklch(95.4% 0.025 65.6), oklch(26.8% 0.011 36.5)); + --color-brand-border: light-dark(oklch(71.0% 0.105 44.7), oklch(52.8% 0.088 49.3)); + --color-on-accent: light-dark(oklch(98.8% 0.009 67.7), oklch(18.8% 0.013 55.5)); + + /* Selected control fill & border (>= 4.4:1 boundary contrast) */ + --color-selected: light-dark(oklch(90.8% 0.023 78.2), oklch(35.3% 0.043 243.0)); + --color-selected-line: light-dark(oklch(49.8% 0.034 72.0), oklch(71.7% 0.046 240.1)); + + /* Semantic status tokens (Herb Sage / Saffron Ochre / Chili) */ + --color-status-ok: light-dark(oklch(44.7% 0.092 154.8), oklch(84.0% 0.149 155.5)); + --color-status-ok-bg: light-dark(oklch(95.5% 0.018 161.1), oklch(27.6% 0.040 162.9)); + --color-status-ok-line: light-dark(oklch(69.9% 0.094 157.2), oklch(52.0% 0.101 156.2)); + + --color-status-warn: light-dark(oklch(47.6% 0.106 63.3), oklch(88.3% 0.120 85.9)); + --color-status-warn-bg: light-dark(oklch(96.7% 0.029 82.6), oklch(26.8% 0.034 78.8)); + --color-status-warn-line: light-dark(oklch(72.6% 0.128 75.7), oklch(55.4% 0.107 76.3)); + + --color-status-danger: light-dark(oklch(48.1% 0.167 26.2), oklch(80.8% 0.103 19.6)); + --color-status-danger-bg: light-dark(oklch(95.2% 0.021 17.5), oklch(24.8% 0.043 13.6)); + --color-status-danger-line: light-dark(oklch(68.1% 0.127 21.0), oklch(49.1% 0.133 18.8)); + + /* Three-voice editorial typography */ + --font-sans: var(--font-jakarta), system-ui, sans-serif; + --font-serif: var(--font-fraunces), Georgia, Cambria, serif; + --font-mono: var(--font-jb-mono), ui-monospace, SFMono-Regular, monospace; + + /* Animations */ + --animate-fade-in: fade-in 0.35s cubic-bezier(0.16, 1, 0.3, 1); + --animate-preview-timer: preview-timer 1s linear forwards; + + @keyframes fade-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: none; + } + } + + @keyframes preview-timer { + from { + transform: scaleX(0); + } + to { + transform: scaleX(1); + } + } +} + +@view-transition { + navigation: auto; +} + +@layer base { + html { + color-scheme: light dark; + } + + html[data-theme="light"] { + color-scheme: light; + } + + html[data-theme="dark"] { + color-scheme: dark; + } + + body { + @apply bg-canvas text-ink antialiased selection:bg-brand-950 selection:text-ink; + } + + /* Visible focus for keyboard users on every interactive element. */ + :where(button, a, input, select, summary, [tabindex]):focus-visible { + @apply outline-2 outline-offset-2 outline-brand-600; + } + + input[type="range"] { + @apply accent-brand-600; + } + + input:not([type="range"], [type="checkbox"]) { + @apply bg-surface text-ink placeholder:text-ink-faint; + } + + /* Native spring entry/exit via 2026 Baseline @starting-style */ + dialog { + color: var(--color-ink); + transition: + opacity 0.22s cubic-bezier(0.16, 1, 0.3, 1), + transform 0.26s cubic-bezier(0.16, 1, 0.3, 1), + display 0.26s allow-discrete, + overlay 0.26s allow-discrete; + opacity: 0; + transform: translateY(14px) scale(0.985); + } + + dialog[open] { + opacity: 1; + transform: translateY(0) scale(1); + + @starting-style { + opacity: 0; + transform: translateY(14px) scale(0.985); + } + } + + dialog.sheet-dialog { + transform: translateY(100%); + } + + dialog.sheet-dialog[open] { + transform: translateY(0); + + @starting-style { + transform: translateY(100%); + } + } + + dialog::backdrop { + background-color: rgb(10 14 18 / 0); + backdrop-filter: blur(0px); + transition: + background-color 0.24s ease, + backdrop-filter 0.24s ease, + display 0.24s allow-discrete, + overlay 0.24s allow-discrete; + } + + dialog[open]::backdrop { + background-color: rgb(10 14 18 / 0.55); + backdrop-filter: blur(2px); + + @starting-style { + background-color: rgb(10 14 18 / 0); + backdrop-filter: blur(0px); + } + } +} + +/* 2026 CSS optical cap-height trimming for buttons, badges & chips */ +.trim-cap { + text-box-trim: trim-both; + text-box-edge: cap alphabetic; +} + +/* Subtle tactile paper noise texture without GPU-heavy blur blobs */ +.paper-grain { + background-image: radial-gradient(color-mix(in oklab, var(--color-ink) 5%, transparent) 1px, transparent 0); + background-size: 18px 18px; +} + +/* Market receipt serrated / perforated divider feel */ +.receipt-rule { + border-top: 1px dashed var(--color-line-strong); +} + +/* Respect the OS "reduce motion" setting everywhere. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..dc3a1a1 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,44 @@ +import type { Metadata, Viewport } from "next"; +import { Fraunces, JetBrains_Mono, Plus_Jakarta_Sans } from "next/font/google"; +import "./globals.css"; + +const jakarta = Plus_Jakarta_Sans({ + variable: "--font-jakarta", + subsets: ["latin"], +}); + +const fraunces = Fraunces({ + variable: "--font-fraunces", + subsets: ["latin"], +}); + +const jetbrainsMono = JetBrains_Mono({ + variable: "--font-jb-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + // Pages set just their own name ("Recipes"); the template adds the app name. + title: { default: "Plan my Meals: Budget Meal Planner", template: "%s · Plan my Meals" }, + description: "Plan a week of meals around your grocery budget and the appliances you actually own.", +}; + +export const viewport: Viewport = { + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#faf6ef" }, + { media: "(prefers-color-scheme: dark)", color: "#0e171f" }, + ], +}; + +const THEME_INIT_SCRIPT = `(function(){try{var t=localStorage.getItem("plan-my-meals:theme");if(t==="light"||t==="dark")document.documentElement.dataset.theme=t;}catch(e){}})();`; + +export default function RootLayout({ children }: LayoutProps<"/">) { + return ( + + +