Feat/meals #1
+163
@@ -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> }`
|
||||
- `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?
|
||||
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)?
|
||||
Reference in New Issue
Block a user