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

6.0 KiB
Raw Blame History

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

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):

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:

  • MealRepositorycreate_meal, get_meal, list_meals, update_meal, delete_meal
  • MealIngredientRepositoryingredients_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 EMealService 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.