169 lines
6.0 KiB
Markdown
169 lines
6.0 KiB
Markdown
# Meal Feature Plan
|
||
|
||
Status: Part A implemented; Parts B–F 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.
|