Feat/meals #1

Merged
sbstp merged 3 commits from feat/meals into master 2026-08-01 20:27:39 -04:00
Showing only changes of commit b71bdb2d5d - Show all commits
+163
View File
@@ -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)?