From 2542f010b1f49ec060a7e35ca2cb140a5b4c468a Mon Sep 17 00:00:00 2001 From: Simon Bernier St-Pierre Date: Sat, 1 Aug 2026 19:09:22 -0400 Subject: [PATCH] make categories global --- MEAL_PLAN.md | 37 +++++++----- README.md | 2 +- src/http.rs | 25 +++----- src/ports.rs | 12 ++-- src/services.rs | 17 ++---- src/sqlite.rs | 151 ++++++++++++++++++++++++++++++------------------ src/views.rs | 21 ++----- 7 files changed, 139 insertions(+), 126 deletions(-) diff --git a/MEAL_PLAN.md b/MEAL_PLAN.md index f73ae2d..cda0ea9 100644 --- a/MEAL_PLAN.md +++ b/MEAL_PLAN.md @@ -1,6 +1,6 @@ # Meal Feature Plan -Status: planning (not yet implemented) +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 @@ -14,11 +14,13 @@ 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`) +### Schema change (in `migrate` in `src/sqlite.rs`) — done ```sql categories ( @@ -29,27 +31,26 @@ categories ( ) ``` -- Drop `list_id`; `name` becomes globally unique. +- Dropped `list_id`; `name` is 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). +- 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`) +### Repo / port changes (`CategoryRepository` in `src/ports.rs`) — done -- `categories(txn)` → returns **all** global categories (no `list_id` param). +- `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. +- Added `category_by_name(txn, name)` for resolving ingredient categories. +- `ListRepository::create_list` no longer seeds default categories. -### Service / HTTP changes +### Service / HTTP changes — done - `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. +- `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. --- @@ -157,7 +158,11 @@ Implemented as an htmx-powered modal that fetches a meal list/search fragment. 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. diff --git a/README.md b/README.md index 52aa32b..4e8602a 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ The file is optional — if it is missing or invalid, seeding is silently skippe - Shared lists with one-time, seven-day invitation links - Invite-only registration by default after the first account - Add, edit, check, and delete grocery items -- List-scoped categories with common defaults and custom category creation +- Global categories with common defaults seeded at startup and custom category creation - Items grouped by category and assigned from the add/edit forms - Server-authoritative last-write-wins updates - Per-list WebSocket updates with server-rendered htmx fragments diff --git a/src/http.rs b/src/http.rs index a3068ff..759537e 100644 --- a/src/http.rs +++ b/src/http.rs @@ -74,7 +74,7 @@ pub fn build_router(state: AppState) -> Router { .route("/lists/{list_id}/items/{item_id}/check", post(check_item)) .route("/lists/{list_id}/items/{item_id}/edit", post(edit_item)) .route("/lists/{list_id}/items/{item_id}/delete", post(delete_item)) - .route("/lists/{list_id}/categories", post(create_category)) + .route("/categories", post(create_category)) .route("/invitations", post(create_invitation)) .route("/lists/{list_id}/stream", get(list_stream)) .route("/invite/{token}", get(invitation_page)) @@ -361,7 +361,7 @@ async fn list_page( ) -> Result { let access = require_list(&state, list_id).await?; let items = state.lists.items(list_id).await?; - let categories = state.lists.categories(list_id).await?; + let categories = state.lists.categories().await?; let presence = state.realtime.presence(list_id).await; Ok(html_response(views::list_page( &user.session.user, @@ -460,28 +460,17 @@ async fn delete_item( async fn create_category( State(state): State, user: CurrentUser, - Path(list_id): Path, LoggedForm(form): LoggedForm, ) -> Result { verify_csrf(&user, &form.csrf)?; - require_list(&state, list_id).await?; let name = form.name.trim().to_owned(); if name.is_empty() || name.chars().count() > 60 { return Err(AppError::BadRequest( "Category names must be between 1 and 60 characters.".into(), )); } - state.lists.create_category(list_id, name).await?; - - let access = require_list(&state, list_id).await?; - let items = state.lists.items(list_id).await?; - let categories = state.lists.categories(list_id).await?; - Ok(html_response(views::category_created( - &access, - &items, - &categories, - &user.session.csrf_token, - ))) + state.lists.create_category(name).await?; + Ok(Redirect::to("/lists").into_response()) } async fn create_invitation( @@ -647,7 +636,7 @@ async fn websocket_snapshot( ) -> Result { let access = require_list(state, list_id).await?; let items = state.lists.items(list_id).await?; - let categories = state.lists.categories(list_id).await?; + let categories = state.lists.categories().await?; Ok( views::live_list_fragments(&access, &items, &categories, &user.session.csrf_token) .into_string() @@ -662,7 +651,7 @@ async fn websocket_list_update( ) -> Result { let access = require_list(state, list_id).await?; let items = state.lists.items(list_id).await?; - let categories = state.lists.categories(list_id).await?; + let categories = state.lists.categories().await?; Ok( views::live_list_fragments(&access, &items, &categories, &user.session.csrf_token) .into_string(), @@ -676,7 +665,7 @@ async fn list_fragment_response( ) -> Result { let access = require_list(state, list_id).await?; let items = state.lists.items(list_id).await?; - let categories = state.lists.categories(list_id).await?; + let categories = state.lists.categories().await?; Ok(html_response(views::list_items_fragment( &access, &items, diff --git a/src/ports.rs b/src/ports.rs index 549815e..9a0f499 100644 --- a/src/ports.rs +++ b/src/ports.rs @@ -60,17 +60,17 @@ pub trait ListRepository: Send + Sync { #[async_trait] pub trait CategoryRepository: Send + Sync { - async fn categories( - &self, - txn: &mut SqliteConnection, - list_id: i64, - ) -> DomainResult>; + async fn categories(&self, txn: &mut SqliteConnection) -> DomainResult>; async fn create_category( &self, txn: &mut SqliteConnection, - list_id: i64, name: String, ) -> DomainResult; + async fn category_by_name( + &self, + txn: &mut SqliteConnection, + name: String, + ) -> DomainResult>; } #[async_trait] diff --git a/src/services.rs b/src/services.rs index d801efe..5d8a9a2 100644 --- a/src/services.rs +++ b/src/services.rs @@ -187,10 +187,10 @@ impl ListService { .await } - pub async fn categories(&self, list_id: i64) -> DomainResult> { + pub async fn categories(&self) -> DomainResult> { let categories = Arc::clone(&self.categories); self.db - .run(move |txn| Box::pin(async move { categories.categories(txn, list_id).await })) + .run(move |txn| Box::pin(async move { categories.categories(txn).await })) .await } @@ -270,16 +270,11 @@ impl ListService { Ok(revision) } - pub async fn create_category(&self, list_id: i64, name: String) -> DomainResult { + pub async fn create_category(&self, name: String) -> DomainResult { let categories = Arc::clone(&self.categories); - let revision = self - .db - .run(move |txn| { - Box::pin(async move { categories.create_category(txn, list_id, name).await }) - }) - .await?; - self.realtime.publish_list_changed(list_id, revision).await; - Ok(revision) + self.db + .run(move |txn| Box::pin(async move { categories.create_category(txn, name).await })) + .await } } diff --git a/src/sqlite.rs b/src/sqlite.rs index 5a28faa..e31f2ac 100644 --- a/src/sqlite.rs +++ b/src/sqlite.rs @@ -27,6 +27,7 @@ impl SqliteDatabase { .create_if_missing(true); let pool = SqlitePool::connect_with(options).await.map_err(db_error)?; migrate(&pool).await?; + seed_default_categories(&pool).await?; Ok(Self { pool }) } @@ -52,6 +53,7 @@ impl SqliteDatabase { .create_if_missing(true); let pool = SqlitePool::connect_with(options).await.map_err(db_error)?; migrate(&pool).await?; + seed_default_categories(&pool).await?; Ok(Self { pool }) } } @@ -109,11 +111,9 @@ async fn migrate(pool: &SqlitePool) -> DomainResult<()> { ); CREATE TABLE IF NOT EXISTS categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, - list_id INTEGER NOT NULL REFERENCES lists(id) ON DELETE CASCADE, - name TEXT NOT NULL COLLATE NOCASE, + name TEXT NOT NULL UNIQUE COLLATE NOCASE, position INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, - UNIQUE (list_id, name) + created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS invitations ( token_hash TEXT PRIMARY KEY, @@ -134,7 +134,6 @@ async fn migrate(pool: &SqlitePool) -> DomainResult<()> { updated_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS items_list_idx ON items(list_id); - CREATE INDEX IF NOT EXISTS categories_list_idx ON categories(list_id); CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id);", ) .execute(pool) @@ -143,6 +142,28 @@ async fn migrate(pool: &SqlitePool) -> DomainResult<()> { Ok(()) } +/// Inserts the default global categories once, if the categories table is empty. +async fn seed_default_categories(pool: &SqlitePool) -> DomainResult<()> { + let count: i64 = sqlx::query("SELECT COUNT(*) FROM categories") + .fetch_one(pool) + .await + .map_err(db_error)? + .get(0); + if count > 0 { + return Ok(()); + } + for (position, category_name) in DEFAULT_CATEGORIES.iter().enumerate() { + sqlx::query("INSERT INTO categories (name, position, created_at) VALUES (?1, ?2, ?3)") + .bind(category_name) + .bind(position as i64) + .bind(now()) + .execute(pool) + .await + .map_err(db_error)?; + } + Ok(()) +} + #[derive(Clone, Copy)] pub struct SqliteUserRepository; @@ -323,19 +344,6 @@ impl ListRepository for SqliteListRepository { .await .map_err(db_error)? .get::(0); - for (position, category_name) in DEFAULT_CATEGORIES.iter().enumerate() { - sqlx::query( - "INSERT INTO categories (list_id, name, position, created_at) - VALUES (?1, ?2, ?3, ?4)", - ) - .bind(list_id) - .bind(category_name) - .bind(position as i64) - .bind(now()) - .execute(&mut *txn) - .await - .map_err(db_error)?; - } Ok(GroceryList { id: list_id, name, @@ -370,18 +378,12 @@ pub struct SqliteCategoryRepository; #[async_trait] impl CategoryRepository for SqliteCategoryRepository { - async fn categories( - &self, - txn: &mut SqliteConnection, - list_id: i64, - ) -> DomainResult> { + async fn categories(&self, txn: &mut SqliteConnection) -> DomainResult> { let rows = sqlx::query( "SELECT id, name FROM categories - WHERE list_id = ?1 ORDER BY position ASC, name COLLATE NOCASE ASC", ) - .bind(list_id) .fetch_all(&mut *txn) .await .map_err(db_error)?; @@ -397,23 +399,17 @@ impl CategoryRepository for SqliteCategoryRepository { async fn create_category( &self, txn: &mut SqliteConnection, - list_id: i64, name: String, ) -> DomainResult { - let position: i64 = sqlx::query( - "SELECT COALESCE(MAX(position), -1) + 1 - FROM categories WHERE list_id = ?1", - ) - .bind(list_id) - .fetch_one(&mut *txn) - .await - .map_err(db_error)? - .get(0); + let position: i64 = sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 FROM categories") + .fetch_one(&mut *txn) + .await + .map_err(db_error)? + .get(0); let result = sqlx::query( - "INSERT INTO categories (list_id, name, position, created_at) - VALUES (?1, ?2, ?3, ?4)", + "INSERT INTO categories (name, position, created_at) + VALUES (?1, ?2, ?3)", ) - .bind(list_id) .bind(&name) .bind(position) .bind(now()) @@ -424,7 +420,32 @@ impl CategoryRepository for SqliteCategoryRepository { Err(error) if is_unique_violation(&error) => return Err(DomainError::Conflict), Err(error) => return Err(db_error(error)), } - bump_revision(txn, list_id).await + let id = sqlx::query("SELECT last_insert_rowid()") + .fetch_one(&mut *txn) + .await + .map_err(db_error)? + .get::(0); + Ok(id) + } + + async fn category_by_name( + &self, + txn: &mut SqliteConnection, + name: String, + ) -> DomainResult> { + let row = sqlx::query( + "SELECT id, name + FROM categories + WHERE name = ?1 COLLATE NOCASE", + ) + .bind(&name) + .fetch_optional(&mut *txn) + .await + .map_err(db_error)?; + Ok(row.map(|row| Category { + id: row.get(0), + name: row.get(1), + })) } } @@ -468,7 +489,7 @@ impl ItemRepository for SqliteItemRepository { note: String, category_id: Option, ) -> DomainResult { - ensure_category(txn, list_id, category_id).await?; + ensure_category(txn, category_id).await?; let position: i64 = sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 FROM items WHERE list_id = ?1") .bind(list_id) @@ -530,7 +551,7 @@ impl ItemRepository for SqliteItemRepository { note: String, category_id: Option, ) -> DomainResult { - ensure_category(txn, list_id, category_id).await?; + ensure_category(txn, category_id).await?; let changed = sqlx::query( "UPDATE items SET name = ?1, quantity = ?2, note = ?3, category_id = ?4, @@ -641,15 +662,13 @@ impl InvitationRepository for SqliteInvitationRepository { async fn ensure_category( txn: &mut SqliteConnection, - list_id: i64, category_id: Option, ) -> DomainResult<()> { let Some(category_id) = category_id else { return Ok(()); }; - let row = sqlx::query("SELECT 1 FROM categories WHERE id = ?1 AND list_id = ?2") + let row = sqlx::query("SELECT 1 FROM categories WHERE id = ?1") .bind(category_id) - .bind(list_id) .fetch_optional(&mut *txn) .await .map_err(db_error)?; @@ -782,11 +801,11 @@ mod tests { .unwrap() } - async fn get_categories(db: &SqliteDatabase, list_id: i64) -> Vec { + async fn get_categories(db: &SqliteDatabase) -> Vec { let categories = SqliteCategoryRepository; db.run(move |txn| { let categories = categories.clone(); - Box::pin(async move { categories.categories(txn, list_id).await }) + Box::pin(async move { categories.categories(txn).await }) }) .await .unwrap() @@ -985,12 +1004,15 @@ mod tests { // ---- ListRepository ---- #[tokio::test] - async fn create_list_seeds_default_categories() { + async fn create_list_does_not_seed_categories() { let db = setup().await; + // Default categories are seeded globally at startup. + let before = get_categories(&db).await.len(); let list = create_list(&db, "Weekly shop").await; assert!(list.id > 0); assert_eq!(list.revision, 0); - assert_eq!(get_categories(&db, list.id).await.len(), 6); + // Categories are global now; creating a list must not add any. + assert_eq!(get_categories(&db).await.len(), before); } #[tokio::test] @@ -1038,39 +1060,36 @@ mod tests { // ---- CategoryRepository ---- #[tokio::test] - async fn create_category_adds_and_bumps_revision() { + async fn create_category_is_global_and_returns_id() { let db = setup().await; - let list = create_list(&db, "Weekly shop").await; let categories = SqliteCategoryRepository; - let revision = db + let id = db .run(move |txn| { let categories = categories.clone(); Box::pin(async move { categories - .create_category(txn, list.id, "Bakery".into()) + .create_category(txn, "Bakery".into()) .await }) }) .await .unwrap(); - assert_eq!(revision, 1); + assert!(id > 0); - let cats = get_categories(&db, list.id).await; - assert_eq!(cats.len(), 7); + let cats = get_categories(&db).await; assert!(cats.iter().any(|c| c.name == "Bakery")); } #[tokio::test] async fn create_duplicate_category_conflicts() { let db = setup().await; - let list = create_list(&db, "Weekly shop").await; let categories = SqliteCategoryRepository; let result = db .run(move |txn| { let categories = categories.clone(); Box::pin(async move { categories - .create_category(txn, list.id, "Produce".into()) + .create_category(txn, "Produce".into()) .await }) }) @@ -1078,6 +1097,24 @@ mod tests { assert!(matches!(result, Err(DomainError::Conflict))); } + #[tokio::test] + async fn category_by_name_resolves_globally() { + let db = setup().await; + let categories = SqliteCategoryRepository; + let found = db + .run(move |txn| { + let categories = categories.clone(); + Box::pin(async move { + categories + .category_by_name(txn, "produce".into()) + .await + }) + }) + .await + .unwrap(); + assert_eq!(found.unwrap().name, "Produce"); + } + // ---- ItemRepository ---- #[tokio::test] diff --git a/src/views.rs b/src/views.rs index 379016e..5ccb271 100644 --- a/src/views.rs +++ b/src/views.rs @@ -175,7 +175,7 @@ pub fn list_page( } aside class="side-column" { (presence_panel(presence, false)) - (categories_panel(list, categories, csrf_token, false)) + (categories_panel(categories, csrf_token, false)) section class="panel tip-panel" { span class="tip-label" { "TIP" } p { "Check items off as you go. Everyone viewing this list will see it instantly." } @@ -395,7 +395,6 @@ fn category_options(categories: &[Category], selected: Option) -> Markup { } pub fn categories_panel( - list: &GroceryList, categories: &[Category], csrf_token: &str, out_of_band: bool, @@ -407,10 +406,10 @@ pub fn categories_panel( } p { "Organize items by aisle or shopping area." } form - hx-post=(format!("/lists/{}/categories", list.id)) + hx-post="/categories" hx-target="#category-result" hx-swap="innerHTML" - hx-on::after-request="if (event.detail.successful) this.reset()" + hx-on::after-request="if (event.detail.successful) window.location.reload()" class="category-form" { input type="hidden" name="csrf" value=(csrf_token); @@ -448,19 +447,7 @@ pub fn live_list_fragments( ) -> Markup { html! { (list_content_fragment(list, items, categories, csrf_token, true)) - (categories_panel(list, categories, csrf_token, true)) - } -} - -pub fn category_created( - list: &GroceryList, - items: &[Item], - categories: &[Category], - csrf_token: &str, -) -> Markup { - html! { - p class="category-success" { "Category added." } - (live_list_fragments(list, items, categories, csrf_token)) + (categories_panel(categories, csrf_token, true)) } }