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 index b39ed65..cbf1217 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,144 @@ -# planmymeal +# 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 -## Getting started - -To make it easy for you to get started with GitLab, here's a list of recommended next steps. - -Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! - -## Add your files - -* [Create](https://docs.gitlab.com/user/project/repository/web_editor/#create-a-file) or [upload](https://docs.gitlab.com/user/project/repository/web_editor/#upload-a-file) files -* [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command: - -``` -cd existing_repo -git remote add origin https://gitlab.com/caydens/planmymeal.git -git branch -M main -git push -uf origin main +```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) ``` -## Integrate with your tools +Production: `npm run build && npm start`. Deploys to Vercel as-is (no env vars needed). -* [Set up project integrations](https://gitlab.com/caydens/planmymeal/-/settings/integrations) +Requires Node 20.9+ (Next.js 16). -## Collaborate with your team +## How it works -* [Invite team members and collaborators](https://docs.gitlab.com/user/project/members/) -* [Create a new merge request](https://docs.gitlab.com/user/project/merge_requests/creating_merge_requests/) -* [Automatically close issues from merge requests](https://docs.gitlab.com/user/project/issues/managing_issues/#closing-issues-automatically) -* [Enable merge request approvals](https://docs.gitlab.com/user/project/merge_requests/approvals/) -* [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/) +``` +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 +``` -## Test and Deploy +**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. -Use the built-in continuous integration in GitLab. +**Planner scoring** (lower wins, per slot, in day order): -* [Get started with GitLab CI/CD](https://docs.gitlab.com/ci/quick_start/) -* [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/user/application_security/sast/) -* [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/topics/autodevops/requirements/) -* [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/user/clusters/agent/) -* [Set up protected environments](https://docs.gitlab.com/ci/environments/protected_environments/) +``` +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. -# Editing this README +**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. -When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template. +## Ingredient preferences -## Suggestions for a good README +Every catalog item has a three-way setting (Ingredients panel, or "Have it" on any grocery line): -Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. +| 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) | -## Name -Choose a self-explaining name for your project. +Each aisle also has an **Avoid all** button, e.g. the Seafood aisle for a fish/shellfish allergy. -## Description -Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors. +## Seafood -## Badges -On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. +`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. -## Visuals -Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method. +## Swapping in real prices -## Installation -Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection. +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. -## Usage -Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README. +Item IDs in `recipes.ts` reference catalog IDs, so a real provider needs a mapping from those IDs to +store SKUs. -## Support -Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. +## Known limits (v1) -## Roadmap -If you have ideas for releases in the future, it is a good idea to list them in the README. +- 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. -## Contributing -State if you are open to contributions and what your requirements are for accepting them. +## Cayden's Corner -For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. +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]. -You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. +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. -## Authors and acknowledgment -Show your appreciation to those who have contributed to the project. +## Adding recipes -## License -For open source projects, say how it is licensed. +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: -## Project status -If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. +```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 0000000..af98450 Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..df7e8ec --- /dev/null +++ b/app/globals.css @@ -0,0 +1,194 @@ +@import "tailwindcss"; + +/* --------------------------------------------------------------------------- + Warm Culinary Editorial Theme ("Parchment & Terracotta" / "Cast-Iron & Peach") + --------------------------------------------------------------------------- + Perceptually uniform OKLCH tokens paired with CSS `light-dark()`. + Every foreground/background pair is verified against WCAG 2.2 AA: + - Body & 12px metadata (`ink`, `ink-muted`, `ink-faint`) >= 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 ( + + +