diff --git a/BACKLOG.md b/BACKLOG.md index ee582ec..7a88507 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -55,10 +55,10 @@ The product-catalog foundation has shipped. A `Product` is the canonical thing b - **Catalog management page (`/products`):** search the effective catalog, add a family product, edit a global product (writes an override) or family product, "Reset to catalog default" for an overridden global, and delete a family product. Linked from the lists page. - **Override indicator:** "Edited" pill on overridden rows in both the catalog page and the typeahead dropdown. - **`ProductStoreSection` write path:** when an item is saved/checked with `(productId, sectionId)`, the mapping is remembered for `(family, store, product)`. +- **Auto-assign section from product on add.** `GET /api/products/{kind}/{id}/section?storeId=...` exposes the remembered mapping; `onItemProductChange` on the list page calls it and pre-fills the section dropdown so the user can see (and override) what the backend would auto-assign. The recipe → list copy path already lands ingredients in remembered sections server-side. - **Seed data:** ~50 hand-curated common-groceries items in `Data/Seed/products.json`, applied by `CatalogSeeder` on startup (idempotent on `Name`). #### Remaining -- **Auto-assign section from product on add.** The write path remembers `(product, store) → section` and the backend has a helper that reads it back, but the add-item form doesn't call it on product pick yet. Described in detail in *Auto-assign section from product* further down. - **"Add '' as a new product" affordance in the typeahead.** Today the typeahead only suggests existing catalog rows; an unmatched name becomes a pure-text item. Adding an explicit affordance to promote that name into a `FamilyProduct` from the same dropdown is still open. - **Seed expansion to ~2–3k curated items.** Current seed file has ~50 entries. Growth is a data exercise, not a code one — keep `products.json` the source of truth, keep the seeder idempotent on `Name`. - **Catalog ingestion tooling (future).** When the curated list starts feeling limiting, build re-runnable importers for public datasets so we don't grow by hand-typing. @@ -133,22 +133,9 @@ Replace free-form `Quantity` strings with a structured `(Quantity, UnitOfMeasure - "A few", "to taste", "some" — these are real recipe quantities that don't fit `(decimal, unit)`. Probably modeled as a special `IsApproximate` flag with an optional `QuantityNote` rather than forcing them into the structured shape. ### Per-store sections — remaining polish -The base feature is shipped (entity, default seed on store create, list view groups by section). Remaining nice-to-haves: -- **Per-store ingredient memory:** remember "last time `Bananas` was bought at Kroger it was in Produce" and auto-assign on next add at that store. Adds an `IngredientSection` mapping table per store. Pairs naturally with the product catalog. -- **Recipes → sections:** when pulling recipe ingredients into a list, map them to the list's store's sections (only meaningful once the per-store ingredient memory or product catalog lands). +The base feature is shipped (entity, default seed on store create, list view groups by section, per-store ingredient memory via `ProductStoreSection` auto-assigning on item add and recipe-to-list copy). Remaining nice-to-haves: - **Section drag-to-reorder** in the store edit UI — section walk order matters, but reordering today only works by editing `SortOrder` numbers manually. -### Auto-assign section from product -When a user picks a product from the typeahead on the shopping list add form, pre-populate the section dropdown with that product's known section for the current store — rather than leaving it as "Uncategorized". - -This is the "per-store ingredient memory" item above, stated from the user's perspective: choosing "Spaghetti" should already know it belongs in Pasta/Dry Goods at this store. - -**State of play:** `ProductStoreSection` is shipped — table, indexes, and the write path that records `(family, store, product) → section` whenever an item with a product link is saved with a chosen section. A backend helper in `ShoppingListEndpoints` already reads the effective section back. **The missing piece is the frontend:** `onItemProductChange` in `lists/[id]/+page.svelte` doesn't call the read path on product pick yet, so the section dropdown still defaults to "Uncategorized" until the user sets it. - -**To finish:** expose the lookup via an endpoint (`GET /api/products/{id}/section?storeId=...` or roll it into the typeahead response when a `storeId` is supplied), call it on `onItemProductChange`, and pre-select the returned section. Same treatment for the recipe → list copy path so adding a recipe to a list lands ingredients in the right sections. - -**Scope note:** the section pre-fill is family-scoped memory (`ProductStoreSection` rows they've created), not a global default — different families organize differently. - ## Recipes ### Structured multi-step instructions diff --git a/src/backend/Directory.Build.props b/src/backend/Directory.Build.props new file mode 100644 index 0000000..80f498a --- /dev/null +++ b/src/backend/Directory.Build.props @@ -0,0 +1,5 @@ + + + true + + diff --git a/src/backend/YesChef.Api.IntegrationTests/Features/ProductEndpointsTests.cs b/src/backend/YesChef.Api.IntegrationTests/Features/ProductEndpointsTests.cs index 7892351..f2d1e30 100644 --- a/src/backend/YesChef.Api.IntegrationTests/Features/ProductEndpointsTests.cs +++ b/src/backend/YesChef.Api.IntegrationTests/Features/ProductEndpointsTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Json; +using System.Text.Json; using YesChef.Api.Entities; using YesChef.Api.Features.Products; using YesChef.Api.IntegrationTests.Infrastructure; @@ -343,9 +344,9 @@ public class ProductEndpointsTests : AuthenticatedIntegrationTest await db.SaveChangesAsync(); }); - var results = await Client.GetFromJsonAsync>("/api/products?q="); + var results = (await Client.GetFromJsonAsync>("/api/products?q="))!; - var milk = results!.Single(r => r.Name == "Milk"); + var milk = results.Single(r => r.Name == "Milk"); await Assert.That(milk.AllowedUnitCategories).IsEqualTo(UnitCategoryFlags.Volume); var eggs = results.Single(r => r.Name == "Eggs"); @@ -362,4 +363,132 @@ public class ProductEndpointsTests : AuthenticatedIntegrationTest var response = await AnonymousClient.GetAsync("/api/products"); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); } + + [Test] + public async Task Get_global_product_section_returns_remembered_section() + { + var familyId = await GetFamilyIdAsync(); + var store = await Data.CreateStoreAsync(); + var (product, section) = await UseDbAsync(async db => + { + var p = new Product { Name = "Bananas-Lookup" }; + var s = new StoreSection { FamilyId = familyId, StoreId = store.Id, Name = "Produce", SortOrder = 1 }; + db.Products.Add(p); + db.StoreSections.Add(s); + await db.SaveChangesAsync(); + db.ProductStoreSections.Add(new ProductStoreSection + { + FamilyId = familyId, + StoreId = store.Id, + ProductId = p.Id, + StoreSectionId = s.Id, + }); + await db.SaveChangesAsync(); + return (p, s); + }); + + var body = await Client.GetFromJsonAsync( + $"/api/products/global/{product.Id}/section?storeId={store.Id}"); + + await Assert.That(body.GetProperty("sectionId").GetInt32()).IsEqualTo(section.Id); + } + + [Test] + public async Task Get_global_product_section_returns_null_when_no_memory() + { + var store = await Data.CreateStoreAsync(); + var product = await UseDbAsync(async db => + { + var p = new Product { Name = "Unknown-Lookup" }; + db.Products.Add(p); + await db.SaveChangesAsync(); + return p; + }); + + var body = await Client.GetFromJsonAsync( + $"/api/products/global/{product.Id}/section?storeId={store.Id}"); + + await Assert.That(body.GetProperty("sectionId").ValueKind).IsEqualTo(JsonValueKind.Null); + } + + [Test] + public async Task Get_global_product_section_is_scoped_to_store() + { + var familyId = await GetFamilyIdAsync(); + var storeA = await Data.CreateStoreAsync(b => b.Named("A")); + var storeB = await Data.CreateStoreAsync(b => b.Named("B")); + var product = await UseDbAsync(async db => + { + var p = new Product { Name = "Apples-Lookup" }; + var sectionA = new StoreSection { FamilyId = familyId, StoreId = storeA.Id, Name = "Produce A", SortOrder = 1 }; + db.Products.Add(p); + db.StoreSections.Add(sectionA); + await db.SaveChangesAsync(); + db.ProductStoreSections.Add(new ProductStoreSection + { + FamilyId = familyId, + StoreId = storeA.Id, + ProductId = p.Id, + StoreSectionId = sectionA.Id, + }); + await db.SaveChangesAsync(); + return p; + }); + + // Lookup at store B — no memory there even though store A has one. + var body = await Client.GetFromJsonAsync( + $"/api/products/global/{product.Id}/section?storeId={storeB.Id}"); + + await Assert.That(body.GetProperty("sectionId").ValueKind).IsEqualTo(JsonValueKind.Null); + } + + [Test] + public async Task Get_family_product_section_returns_remembered_section() + { + var familyId = await GetFamilyIdAsync(); + var store = await Data.CreateStoreAsync(); + var (product, section) = await UseDbAsync(async db => + { + var p = new FamilyProduct { FamilyId = familyId, Name = "House Bread" }; + var s = new StoreSection { FamilyId = familyId, StoreId = store.Id, Name = "Bakery", SortOrder = 1 }; + db.FamilyProducts.Add(p); + db.StoreSections.Add(s); + await db.SaveChangesAsync(); + db.ProductStoreSections.Add(new ProductStoreSection + { + FamilyId = familyId, + StoreId = store.Id, + FamilyProductId = p.Id, + StoreSectionId = s.Id, + }); + await db.SaveChangesAsync(); + return (p, s); + }); + + var body = await Client.GetFromJsonAsync( + $"/api/products/family/{product.Id}/section?storeId={store.Id}"); + + await Assert.That(body.GetProperty("sectionId").GetInt32()).IsEqualTo(section.Id); + } + + [Test] + public async Task Get_family_product_section_returns_404_for_other_family_product() + { + var store = await Data.CreateStoreAsync(); + var otherFamilyProductId = await UseDbAsync(async db => + { + var otherFamily = new Family { Name = "Other", InviteCode = "other-code" }; + db.Families.Add(otherFamily); + await db.SaveChangesAsync(); + var p = new FamilyProduct { FamilyId = otherFamily.Id, Name = "Their Bread" }; + db.FamilyProducts.Add(p); + await db.SaveChangesAsync(); + return p.Id; + }); + + var response = await Client.GetAsync( + $"/api/products/family/{otherFamilyProductId}/section?storeId={store.Id}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } } diff --git a/src/backend/YesChef.Api.IntegrationTests/Features/RecipeEndpointsTests.cs b/src/backend/YesChef.Api.IntegrationTests/Features/RecipeEndpointsTests.cs index c8a946b..898884a 100644 --- a/src/backend/YesChef.Api.IntegrationTests/Features/RecipeEndpointsTests.cs +++ b/src/backend/YesChef.Api.IntegrationTests/Features/RecipeEndpointsTests.cs @@ -111,7 +111,7 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest await Assert.That(body.GetProperty("title").GetString()).IsEqualTo("Soup"); await Assert.That(body.GetProperty("createdBy").GetString()).IsEqualTo(User.Name); var ingredientNames = body.GetProperty("ingredients").EnumerateArray() - .Select(i => i.GetProperty("name").GetString()).ToArray(); + .Select(i => i.GetProperty("name").GetString()!).ToArray(); await Assert.That(ingredientNames).IsEquivalentTo(new[] { "broth cube", "water" }); } @@ -131,7 +131,7 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest var hits = await Client.GetFromJsonAsync>("/api/recipes?q=Pa"); - await Assert.That(hits!.Select(h => h.GetProperty("title").GetString())) + await Assert.That(hits!.Select(h => h.GetProperty("title").GetString()!)) .IsEquivalentTo(new[] { "Pancakes", "Pad Thai" }); } diff --git a/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs b/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs index 6b9838b..d56705a 100644 --- a/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs +++ b/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs @@ -77,7 +77,7 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest await Assert.That(body.GetProperty("name").GetString()).IsEqualTo("groceries"); var items = body.GetProperty("items").EnumerateArray() - .Select(i => i.GetProperty("name").GetString()).ToArray(); + .Select(i => i.GetProperty("name").GetString()!).ToArray(); await Assert.That(items).IsEquivalentTo(new[] { "bread", "milk" }); } @@ -377,7 +377,7 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest var body = await Client.GetFromJsonAsync($"/api/lists/{list.Id}"); var items = body.GetProperty("items").EnumerateArray() - .Select(i => i.GetProperty("name").GetString()).ToArray(); + .Select(i => i.GetProperty("name").GetString()!).ToArray(); await Assert.That(items).IsEquivalentTo(new[] { "kept" }); } diff --git a/src/backend/YesChef.Api.IntegrationTests/Infrastructure/PostgresFixture.cs b/src/backend/YesChef.Api.IntegrationTests/Infrastructure/PostgresFixture.cs index 9dd41e7..1f84fda 100644 --- a/src/backend/YesChef.Api.IntegrationTests/Infrastructure/PostgresFixture.cs +++ b/src/backend/YesChef.Api.IntegrationTests/Infrastructure/PostgresFixture.cs @@ -1,4 +1,3 @@ -using Microsoft.EntityFrameworkCore; using Npgsql; using Testcontainers.PostgreSql; using TUnit.Core.Interfaces; @@ -16,8 +15,7 @@ public sealed class PostgresFixture : IAsyncInitializer, IAsyncDisposable { private const string TemplateDbName = "yeschef_template"; - private readonly PostgreSqlContainer _container = new PostgreSqlBuilder() - .WithImage("postgres:17") + private readonly PostgreSqlContainer _container = new PostgreSqlBuilder("postgres:17") .WithDatabase("postgres") .WithUsername("postgres") .WithPassword("postgres") diff --git a/src/backend/YesChef.Api/Entities/UnitCategory.cs b/src/backend/YesChef.Api/Entities/UnitCategory.cs index ab97b3a..73fd36b 100644 --- a/src/backend/YesChef.Api/Entities/UnitCategory.cs +++ b/src/backend/YesChef.Api/Entities/UnitCategory.cs @@ -1,5 +1,8 @@ +using System.Text.Json.Serialization; + namespace YesChef.Api.Entities; +[JsonConverter(typeof(JsonStringEnumConverter))] public enum UnitCategory { Count = 0, diff --git a/src/backend/YesChef.Api/Entities/UnitCategoryFlags.cs b/src/backend/YesChef.Api/Entities/UnitCategoryFlags.cs index 00b867b..75b73e4 100644 --- a/src/backend/YesChef.Api/Entities/UnitCategoryFlags.cs +++ b/src/backend/YesChef.Api/Entities/UnitCategoryFlags.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace YesChef.Api.Entities; /// @@ -7,6 +9,7 @@ namespace YesChef.Api.Entities; /// 32-bit integer so families can OR in additional categories over time. /// [System.Flags] +[JsonConverter(typeof(JsonStringEnumConverter))] public enum UnitCategoryFlags { None = 0, diff --git a/src/backend/YesChef.Api/Features/Products/ProductEndpoints.cs b/src/backend/YesChef.Api/Features/Products/ProductEndpoints.cs index 920e7b8..bfb28ac 100644 --- a/src/backend/YesChef.Api/Features/Products/ProductEndpoints.cs +++ b/src/backend/YesChef.Api/Features/Products/ProductEndpoints.cs @@ -1,7 +1,9 @@ +using System.Text.Json.Serialization; using Microsoft.EntityFrameworkCore; using YesChef.Api.Auth; using YesChef.Api.Data; using YesChef.Api.Entities; +using YesChef.Api.Features.ShoppingLists; namespace YesChef.Api.Features.Products; @@ -10,6 +12,7 @@ public static class ProductEndpoints /// Discriminator on product DTOs so the client can route subsequent /// PUT/POST calls to the right code path. The catalog UI itself doesn't /// need to surface this — both kinds render the same way. + [JsonConverter(typeof(JsonStringEnumConverter))] public enum ProductKind { Global, Family } public record ProductDto( @@ -239,6 +242,30 @@ public static class ProductEndpoints ovr.AllowedUnitCategories ?? product.AllowedUnitCategories)); }); + // Look up the per-store section memory for a product so the add-item + // UI can pre-fill the dropdown the moment the user picks a product + // from the typeahead. The backend already applies this memory on POST + // when no section is supplied — this endpoint just lets the client + // mirror that decision so the user can see (and override) it. + // Returns { sectionId: int | null }. No memory → null, not 404. + group.MapGet("/global/{id:int}/section", async (int id, int storeId, YesChefDb db, HttpContext http) => + { + var familyId = http.User.GetFamilyId(); + var sectionId = await ShoppingListEndpoints.LookupRememberedSectionAsync(db, familyId, storeId, id, null); + return Results.Ok(new { sectionId }); + }); + + group.MapGet("/family/{id:int}/section", async (int id, int storeId, YesChefDb db, HttpContext http) => + { + var familyId = http.User.GetFamilyId(); + // FamilyProducts are tenant-scoped — refuse to leak even the + // existence of another family's product id. + if (!await db.FamilyProducts.AnyAsync(p => p.Id == id && p.FamilyId == familyId)) + return Results.NotFound(); + var sectionId = await ShoppingListEndpoints.LookupRememberedSectionAsync(db, familyId, storeId, null, id); + return Results.Ok(new { sectionId }); + }); + return group; } diff --git a/src/backend/YesChef.Api/Features/Units/UnitEndpoints.cs b/src/backend/YesChef.Api/Features/Units/UnitEndpoints.cs index 1014c5f..f223ac5 100644 --- a/src/backend/YesChef.Api/Features/Units/UnitEndpoints.cs +++ b/src/backend/YesChef.Api/Features/Units/UnitEndpoints.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Microsoft.EntityFrameworkCore; using YesChef.Api.Auth; using YesChef.Api.Data; @@ -9,6 +10,7 @@ public static class UnitEndpoints { /// Discriminator so the client can route subsequent PUT/DELETE /// calls to the right code path. Global units cannot be edited. + [JsonConverter(typeof(JsonStringEnumConverter))] public enum UnitKind { Global, Family } public record UnitDto( diff --git a/src/frontend/src/routes/lists/[id]/+page.svelte b/src/frontend/src/routes/lists/[id]/+page.svelte index 719e330..045fb1d 100644 --- a/src/frontend/src/routes/lists/[id]/+page.svelte +++ b/src/frontend/src/routes/lists/[id]/+page.svelte @@ -56,6 +56,9 @@ let newItemAllowedUnitCategories = $state(0); let loading = $state(true); let connection: HubConnection | null = null; + // Seq guard so a slow section lookup can't overwrite the section a + // later (faster) product pick already set. + let sectionLookupSeq = 0; const listId = $derived(Number(page.params.id)); const uncheckedItems = $derived(items.filter((i) => !i.isChecked)); @@ -203,19 +206,39 @@ } } - function onItemProductChange(product: ProductSuggestion | null) { + async function onItemProductChange(product: ProductSuggestion | null) { + const seq = ++sectionLookupSeq; if (product === null) { newItemProductId = null; newItemFamilyProductId = null; newItemAllowedUnitCategories = 0; - } else if (product.kind === 'Global') { + // Don't clobber the user's section choice when they edit the + // name and the link clears — they may have set it deliberately. + return; + } + if (product.kind === 'Global') { newItemProductId = product.id; newItemFamilyProductId = null; - newItemAllowedUnitCategories = product.allowedUnitCategories; } else { newItemProductId = null; newItemFamilyProductId = product.id; - newItemAllowedUnitCategories = product.allowedUnitCategories; + } + newItemAllowedUnitCategories = product.allowedUnitCategories; + + // Mirror the backend's auto-assign rule: pre-fill the section dropdown + // from per-store memory so the user can see (and override) what's + // about to happen. Null result means no memory — reset to "No section". + if (!list) return; + const path = product.kind === 'Global' ? 'global' : 'family'; + try { + const res = await api<{ sectionId: number | null }>( + `/api/products/${path}/${product.id}/section?storeId=${list.store.id}` + ); + if (seq !== sectionLookupSeq) return; + newItemSectionId = res.sectionId; + } catch { + // Lookup failure shouldn't block adding an item — leave the + // section alone and let the backend auto-assign on POST. } }