5.8 KiB
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)
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;namebecomes globally unique. items.category_idstays a FK tocategories(id)— unchanged.- Migration concern: the current
CREATE TABLE IF NOT EXISTSwon'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 (nolist_idparam).create_category(txn, name)→ global, nolist_id, no per-list revision bump.- Add
category_by_name(txn, name)for resolving ingredient categories. ListRepository::create_listno 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 takeslist_id.create_categoryhandler moves from/lists/{list_id}/categoriesto a global/categoriesroute (or a categories management page).- The list page's categories panel now shows the global category set.
create_categoryno 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):
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.iddirectly (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_mealMealIngredientRepository—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:- Load the meal.
- Verify the list exists.
- Map each ingredient's
category_id(already a global category id, so it's valid on the list directly). - 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 mealsGET /meals/new,POST /meals— createGET /meals/{id},POST /meals/{id}/edit— view/editPOST /meals/{id}/deletePOST /meals/{id}/ingredients— add ingredientPOST /meals/{id}/ingredients/{iid}/edit,.../deletePOST /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
- Part A — category refactor (schema, repos, services, HTTP, views, tests). Do this first since meals depend on global categories.
- Part B/C/D — meal schema + domain + repos + tests.
- Part E —
MealServiceCRUD +add_meal_to_list(bulk) + tests. - Part F — HTTP routes, views, and the add-meal lookup popup.
Open decisions (to confirm before implementing)
- Migration handling for the category refactor — since
CREATE TABLE IF NOT EXISTSwon't reshape an existing DB, write a proper migration, or is it fine to drop/recreate the dev DB? - 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)?