diff --git a/MEAL_PLAN.md b/MEAL_PLAN.md new file mode 100644 index 0000000..f73ae2d --- /dev/null +++ b/MEAL_PLAN.md @@ -0,0 +1,163 @@ +# 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)?