# Meal Feature Plan Status: planning (not yet implemented) This plan adds the concept of a **meal** to Sustenance. A meal has a name, an optional description/recipe (markdown), and a list of ingredients. Meals are **global** (not owned by a single user) and **not collaborative** like grocery lists. The core action is **"add a meal to a list"**, which expands the meal's ingredients into regular list items. The plan is split into parts so each can be implemented and tested independently. --- ## Part A — Refactor categories to be global Currently `categories` are per-list (`categories.list_id`, with a `UNIQUE (list_id, name)` constraint). Since meals are global and ingredients reference categories, categories become global too. ### Schema change (in `migrate` in `src/sqlite.rs`) ```sql categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE COLLATE NOCASE, position INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL ) ``` - Drop `list_id`; `name` becomes globally unique. - `items.category_id` stays a FK to `categories(id)` — unchanged. - **Migration concern:** the current `CREATE TABLE IF NOT EXISTS` won't alter an existing DB. Need a real migration (or accept recreating the dev DB). ### Repo / port changes (`CategoryRepository` in `src/ports.rs`) - `categories(txn)` → returns **all** global categories (no `list_id` param). - `create_category(txn, name)` → global, no `list_id`, no per-list revision bump. - Add `category_by_name(txn, name)` for resolving ingredient categories. - `ListRepository::create_list` **no longer seeds** default categories (they're global now). Default categories become a one-time seed at startup instead. ### Service / HTTP changes - `ListService::categories()` no longer takes `list_id`. - `create_category` handler moves from `/lists/{list_id}/categories` to a global `/categories` route (or a categories management page). - The list page's categories panel now shows the global category set. - `create_category` no longer bumps a list revision (not list-scoped anymore), so no realtime event for it. --- ## Part B — Meal data model New tables (in `migrate` in `src/sqlite.rs`): ```sql meals ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', -- markdown source created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ) meal_ingredients ( id INTEGER PRIMARY KEY AUTOINCREMENT, meal_id INTEGER NOT NULL REFERENCES meals(id) ON DELETE CASCADE, name TEXT NOT NULL, quantity TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '', category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, -- global category FK position INTEGER NOT NULL DEFAULT 0 ) ``` - **No `user_id`** — meals are global (editable/accessible by all), matching lists. - Ingredient categories reference the **global** `categories.id` directly (thanks to Part A), no name-string resolution needed. --- ## Part C — Domain models (`src/domain.rs`) - `Meal { id, name, description, ingredients: Vec }` - `MealIngredient { id, name, quantity, note, category_id: Option }` --- ## Part D — Ports (`src/ports.rs`) One repo per table, matching the existing pattern: - `MealRepository` — `create_meal`, `get_meal`, `list_meals`, `update_meal`, `delete_meal` - `MealIngredientRepository` — `ingredients_for_meal`, `add_ingredient`, `update_ingredient`, `delete_ingredient` - Reuse `ListRepository`, `CategoryRepository`, `ItemRepository`. --- ## Part E — Services (`src/services.rs`) - `MealService` — CRUD for meals + ingredients. - **`add_meal_to_list(meal_id, list_id)`** in one unit of work: 1. Load the meal. 2. Verify the list exists. 3. Map each ingredient's `category_id` (already a global category id, so it's valid on the list directly). 4. **Bulk-insert** all items via a new `ItemRepository::add_items_bulk`, bumping the list revision **once** → one realtime event. --- ## Part F — HTTP + Views ### Routes (all require `CurrentUser`) - `GET /meals` — list all meals - `GET /meals/new`, `POST /meals` — create - `GET /meals/{id}`, `POST /meals/{id}/edit` — view/edit - `POST /meals/{id}/delete` - `POST /meals/{id}/ingredients` — add ingredient - `POST /meals/{id}/ingredients/{iid}/edit`, `.../delete` - `POST /lists/{list_id}/add-meal` — add a meal's ingredients to a list ### Add-to-list lookup popup On the list page, an "Add meal" button opens a modal/popup with a searchable meal picker (htmx). Selecting a meal posts to `/lists/{list_id}/add-meal`. Implemented as an htmx-powered modal that fetches a meal list/search fragment. ### Views (`src/views.rs`) - Meals index page (`/meals`) listing all meals. - Full-page create/edit forms (matches current htmx style). - Meal detail page showing name, rendered description, and ingredients. - Markdown rendered server-side with `pulldown-cmark`, no sanitization for now. --- ## Implementation order 1. **Part A** — category refactor (schema, repos, services, HTTP, views, tests). Do this first since meals depend on global categories. 2. **Part B/C/D** — meal schema + domain + repos + tests. 3. **Part E** — `MealService` CRUD + `add_meal_to_list` (bulk) + tests. 4. **Part F** — HTTP routes, views, and the add-meal lookup popup. --- ## Open decisions (to confirm before implementing) 1. **Migration handling for the category refactor** — since `CREATE TABLE IF NOT EXISTS` won't reshape an existing DB, write a proper migration, or is it fine to drop/recreate the dev DB? 2. **Category management UI** — with categories now global, do we want a dedicated categories page (e.g. `/categories`) to add/rename/delete them, or keep it minimal (just the add form on the list page, now creating global categories)?