Files
sustenance/MEAL_PLAN.md
T
2026-08-01 19:09:22 -04:00

169 lines
6.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Meal Feature Plan
Status: Part A implemented; Parts BF 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
**Status: implemented**
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`) — done
```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
)
```
- Dropped `list_id`; `name` is globally unique.
- `items.category_id` stays a FK to `categories(id)` — unchanged.
- Default categories are now seeded once at startup via `seed_default_categories`
(called from `SqliteDatabase::open` / `open_in_memory`).
- **Migration:** since `CREATE TABLE IF NOT EXISTS` won't reshape an existing DB,
the dev DB is recreated (see Open decisions).
### Repo / port changes (`CategoryRepository` in `src/ports.rs`) — done
- `categories(txn)` → returns all global categories (no `list_id` param).
- `create_category(txn, name)` → global, no `list_id`, no per-list revision bump.
- Added `category_by_name(txn, name)` for resolving ingredient categories.
- `ListRepository::create_list` no longer seeds default categories.
### Service / HTTP changes — done
- `ListService::categories()` no longer takes `list_id`.
- `create_category` handler moved to a global `POST /categories` route.
- The list page's categories panel shows the global category set.
- `create_category` no longer bumps a list revision, 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> }`
- `MealIngredient { id, name, quantity, note, category_id: Option<i64> }`
---
## 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?
**Resolution (Part A):** drop/recreate the dev DB. The app was never
deployed, so there is no production data to preserve.
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)?
**Resolution (Part A):** keep it minimal — the add form stays on the list
page's categories panel, now posting to the global `POST /categories` route.