From c7f9c319527aaf8930e1e2c756f9023bad4d5779 Mon Sep 17 00:00:00 2001 From: Josh Rogers Date: Tue, 12 May 2026 21:36:25 -0500 Subject: [PATCH] Add structured quantities + units to recipe ingredients Phase 2 of structured quantities + UoM. Replaces the free-form Quantity string on RecipeIngredient with a structured (Quantity decimal, UnitOfMeasureId or FamilyUnitOfMeasureId) pair, plus an IsApproximate + QuantityNote escape hatch for "to taste" style entries. The unit FK pair mirrors the existing Product / FamilyProduct pattern, with the same at-most-one and tenant-scoping validation. Existing string Quantity values are dropped per the agreed wipe-to-null migration plan. Frontend ships a QuantityInput component (numeric field + unit dropdown fed by a runes-cached effective catalog from /api/units) and a shared formatter for read-only display. Recipe -> shopping list copy folds the structured quantity into the item Name for now; Phase 3 will move the fields onto ShoppingListItem directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Builders/RecipeBuilder.cs | 23 +- .../Features/RecipeEndpointsTests.cs | 143 ++- .../Features/ShoppingListEndpointsTests.cs | 19 +- .../Features/UnitEndpointsTests.cs | 37 + src/backend/YesChef.Api/Data/YesChefDb.cs | 8 +- .../YesChef.Api/Entities/RecipeIngredient.cs | 13 +- .../Features/Recipes/RecipeEndpoints.cs | 86 +- .../ShoppingLists/ShoppingListEndpoints.cs | 49 +- .../Features/Units/UnitEndpoints.cs | 8 +- ...uredRecipeIngredientQuantities.Designer.cs | 1056 +++++++++++++++++ ...AddStructuredRecipeIngredientQuantities.cs | 129 ++ .../Migrations/YesChefDbModelSnapshot.cs | 37 +- src/frontend/src/lib/QuantityInput.svelte | 78 ++ src/frontend/src/lib/formatQuantity.ts | 47 + src/frontend/src/lib/units.svelte.ts | 54 + .../src/routes/recipes/[id]/+page.svelte | 15 +- .../src/routes/recipes/new/+page.svelte | 92 +- 17 files changed, 1805 insertions(+), 89 deletions(-) create mode 100644 src/backend/YesChef.Api/Migrations/20260513022332_AddStructuredRecipeIngredientQuantities.Designer.cs create mode 100644 src/backend/YesChef.Api/Migrations/20260513022332_AddStructuredRecipeIngredientQuantities.cs create mode 100644 src/frontend/src/lib/QuantityInput.svelte create mode 100644 src/frontend/src/lib/formatQuantity.ts create mode 100644 src/frontend/src/lib/units.svelte.ts diff --git a/src/backend/YesChef.Api.IntegrationTests/Builders/RecipeBuilder.cs b/src/backend/YesChef.Api.IntegrationTests/Builders/RecipeBuilder.cs index 3b779d7..fcb8e58 100644 --- a/src/backend/YesChef.Api.IntegrationTests/Builders/RecipeBuilder.cs +++ b/src/backend/YesChef.Api.IntegrationTests/Builders/RecipeBuilder.cs @@ -24,9 +24,28 @@ public sealed class RecipeBuilder public RecipeBuilder ForFamily(int familyId) { _familyId = familyId; return this; } public RecipeBuilder ForFamily(Family family) { _familyId = family.Id; return this; } - public RecipeBuilder WithIngredient(string name, string? quantity = null, int sortOrder = 0) + public RecipeBuilder WithIngredient(string name, int sortOrder = 0, decimal? quantity = null, int? unitOfMeasureId = null, int? familyUnitOfMeasureId = null) { - _ingredients.Add(new RecipeIngredient { Name = name, Quantity = quantity, SortOrder = sortOrder }); + _ingredients.Add(new RecipeIngredient + { + Name = name, + SortOrder = sortOrder, + Quantity = quantity, + UnitOfMeasureId = unitOfMeasureId, + FamilyUnitOfMeasureId = familyUnitOfMeasureId, + }); + return this; + } + + public RecipeBuilder WithApproximateIngredient(string name, string quantityNote, int sortOrder = 0) + { + _ingredients.Add(new RecipeIngredient + { + Name = name, + SortOrder = sortOrder, + IsApproximate = true, + QuantityNote = quantityNote, + }); return this; } diff --git a/src/backend/YesChef.Api.IntegrationTests/Features/RecipeEndpointsTests.cs b/src/backend/YesChef.Api.IntegrationTests/Features/RecipeEndpointsTests.cs index b6ccad6..0e53050 100644 --- a/src/backend/YesChef.Api.IntegrationTests/Features/RecipeEndpointsTests.cs +++ b/src/backend/YesChef.Api.IntegrationTests/Features/RecipeEndpointsTests.cs @@ -24,8 +24,8 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest SourceUrl: "https://example.com/pasta", Ingredients: [ - new RecipeEndpoints.IngredientRequest("pasta", "1 lb", 1), - new RecipeEndpoints.IngredientRequest("salt", null, 2), + new RecipeEndpoints.IngredientRequest("pasta", 1, Quantity: 1m), + new RecipeEndpoints.IngredientRequest("salt", 2), ]); var response = await Client.PostAsJsonAsync("/api/recipes", request); @@ -54,7 +54,7 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest Instructions: null, Servings: null, SourceUrl: null, - Ingredients: [new RecipeEndpoints.IngredientRequest("pasta", "1 lb", 1, ProductId: product.Id)]); + Ingredients: [new RecipeEndpoints.IngredientRequest("pasta", 1, Quantity: 1m, ProductId: product.Id)]); var response = await Client.PostAsJsonAsync("/api/recipes", request); @@ -68,7 +68,7 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest { var request = new RecipeEndpoints.CreateRecipeRequest( Title: "x", Description: null, Instructions: null, Servings: null, SourceUrl: null, - Ingredients: [new RecipeEndpoints.IngredientRequest("x", null, 1, ProductId: 1, FamilyProductId: 1)]); + Ingredients: [new RecipeEndpoints.IngredientRequest("x", 1, ProductId: 1, FamilyProductId: 1)]); var response = await Client.PostAsJsonAsync("/api/recipes", request); @@ -91,7 +91,7 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest var request = new RecipeEndpoints.CreateRecipeRequest( Title: "x", Description: null, Instructions: null, Servings: null, SourceUrl: null, - Ingredients: [new RecipeEndpoints.IngredientRequest("x", null, 1, FamilyProductId: foreignProductId)]); + Ingredients: [new RecipeEndpoints.IngredientRequest("x", 1, FamilyProductId: foreignProductId)]); var response = await Client.PostAsJsonAsync("/api/recipes", request); @@ -103,8 +103,8 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest { var recipe = await CreateRecipeAsync(b => b .Titled("Soup").Describes("warm").Serves(2) - .WithIngredient("water", "4 cups", 2) - .WithIngredient("broth cube", "1", 1)); + .WithIngredient("water", sortOrder: 2, quantity: 4m) + .WithIngredient("broth cube", sortOrder: 1, quantity: 1m)); var body = await Client.GetFromJsonAsync($"/api/recipes/{recipe.Id}"); @@ -140,8 +140,8 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest { var recipe = await CreateRecipeAsync(b => b .Titled("Stew") - .WithIngredient("beef", "1 lb", 1) - .WithIngredient("carrot", "2", 2)); + .WithIngredient("beef", sortOrder: 1, quantity: 1m) + .WithIngredient("carrot", sortOrder: 2, quantity: 2m)); var update = new RecipeEndpoints.UpdateRecipeRequest( Title: "Veggie Stew", @@ -151,9 +151,9 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest SourceUrl: null, Ingredients: [ - new RecipeEndpoints.IngredientRequest("potato", "3", 1), - new RecipeEndpoints.IngredientRequest("onion", "1", 2), - new RecipeEndpoints.IngredientRequest("carrot", "4", 3), + new RecipeEndpoints.IngredientRequest("potato", 1, Quantity: 3m), + new RecipeEndpoints.IngredientRequest("onion", 2, Quantity: 1m), + new RecipeEndpoints.IngredientRequest("carrot", 3, Quantity: 4m), ]); var response = await Client.PutAsJsonAsync($"/api/recipes/{recipe.Id}", update); @@ -173,7 +173,7 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest { var recipe = await CreateRecipeAsync(b => b .Titled("Toast") - .WithIngredient("bread", "2 slices", 1)); + .WithIngredient("bread", sortOrder: 1, quantity: 2m)); var response = await Client.DeleteAsync($"/api/recipes/{recipe.Id}"); @@ -181,4 +181,121 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest await Assert.That(await UseDbAsync(db => db.Recipes.CountAsync())).IsEqualTo(0); await Assert.That(await UseDbAsync(db => db.RecipeIngredients.CountAsync())).IsEqualTo(0); } + + [Test] + public async Task Create_persists_structured_quantity_and_global_unit() + { + var unitId = await UseDbAsync(async db => + { + var u = new UnitOfMeasure + { + Code = "lb", SingularName = "pound", PluralName = "pounds", + Abbreviation = "lb", Category = UnitCategory.Weight, + }; + db.UnitsOfMeasure.Add(u); + await db.SaveChangesAsync(); + return u.Id; + }); + + var request = new RecipeEndpoints.CreateRecipeRequest( + Title: "Burger", Description: null, Instructions: null, Servings: null, SourceUrl: null, + Ingredients: [new RecipeEndpoints.IngredientRequest("beef", 1, Quantity: 1.5m, UnitOfMeasureId: unitId)]); + + var response = await Client.PostAsJsonAsync("/api/recipes", request); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + var ing = await UseDbAsync(db => db.RecipeIngredients.SingleAsync()); + await Assert.That(ing.Quantity).IsEqualTo(1.5m); + await Assert.That(ing.UnitOfMeasureId).IsEqualTo(unitId); + await Assert.That(ing.FamilyUnitOfMeasureId).IsNull(); + } + + [Test] + public async Task Create_persists_approximate_ingredient_with_note() + { + var request = new RecipeEndpoints.CreateRecipeRequest( + Title: "Stew", Description: null, Instructions: null, Servings: null, SourceUrl: null, + Ingredients: [new RecipeEndpoints.IngredientRequest("salt", 1, IsApproximate: true, QuantityNote: "to taste")]); + + var response = await Client.PostAsJsonAsync("/api/recipes", request); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + var ing = await UseDbAsync(db => db.RecipeIngredients.SingleAsync()); + await Assert.That(ing.IsApproximate).IsTrue(); + await Assert.That(ing.QuantityNote).IsEqualTo("to taste"); + await Assert.That(ing.Quantity).IsNull(); + await Assert.That(ing.UnitOfMeasureId).IsNull(); + } + + [Test] + public async Task Create_rejects_ingredient_with_both_unit_ids() + { + var request = new RecipeEndpoints.CreateRecipeRequest( + Title: "x", Description: null, Instructions: null, Servings: null, SourceUrl: null, + Ingredients: [new RecipeEndpoints.IngredientRequest("x", 1, Quantity: 1m, UnitOfMeasureId: 1, FamilyUnitOfMeasureId: 1)]); + + var response = await Client.PostAsJsonAsync("/api/recipes", request); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task Create_rejects_ingredient_referencing_other_familys_unit() + { + var foreignUnitId = await UseDbAsync(async db => + { + var other = new Family { Name = "Other", InviteCode = "other-code" }; + db.Families.Add(other); + await db.SaveChangesAsync(); + var u = new FamilyUnitOfMeasure + { + FamilyId = other.Id, + SingularName = "pinch", PluralName = "pinches", + Abbreviation = "pn", Category = UnitCategory.Volume, + }; + db.FamilyUnitsOfMeasure.Add(u); + await db.SaveChangesAsync(); + return u.Id; + }); + + var request = new RecipeEndpoints.CreateRecipeRequest( + Title: "x", Description: null, Instructions: null, Servings: null, SourceUrl: null, + Ingredients: [new RecipeEndpoints.IngredientRequest("salt", 1, Quantity: 1m, FamilyUnitOfMeasureId: foreignUnitId)]); + + var response = await Client.PostAsJsonAsync("/api/recipes", request); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task Get_projects_structured_quantity_fields() + { + var unitId = await UseDbAsync(async db => + { + var u = new UnitOfMeasure + { + Code = "cup", SingularName = "cup", PluralName = "cups", + Abbreviation = "cup", Category = UnitCategory.Volume, + }; + db.UnitsOfMeasure.Add(u); + await db.SaveChangesAsync(); + return u.Id; + }); + var recipe = await CreateRecipeAsync(b => b + .Titled("Pancakes") + .WithIngredient("flour", sortOrder: 1, quantity: 2m, unitOfMeasureId: unitId) + .WithApproximateIngredient("salt", quantityNote: "to taste", sortOrder: 2)); + + var body = await Client.GetFromJsonAsync($"/api/recipes/{recipe.Id}"); + + var ingredients = body.GetProperty("ingredients").EnumerateArray().ToArray(); + var flour = ingredients.Single(i => i.GetProperty("name").GetString() == "flour"); + await Assert.That(flour.GetProperty("quantity").GetDecimal()).IsEqualTo(2m); + await Assert.That(flour.GetProperty("unitOfMeasureId").GetInt32()).IsEqualTo(unitId); + await Assert.That(flour.GetProperty("isApproximate").GetBoolean()).IsFalse(); + + var salt = ingredients.Single(i => i.GetProperty("name").GetString() == "salt"); + await Assert.That(salt.GetProperty("isApproximate").GetBoolean()).IsTrue(); + await Assert.That(salt.GetProperty("quantityNote").GetString()).IsEqualTo("to taste"); + } } diff --git a/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs b/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs index a1366c2..6a79278 100644 --- a/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs +++ b/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs @@ -443,12 +443,23 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest [Test] public async Task Add_recipe_appends_all_ingredients_with_quantity_prefix() { + var cupId = await UseDbAsync(async db => + { + var u = new UnitOfMeasure + { + Code = "cup", SingularName = "cup", PluralName = "cups", + Abbreviation = "cup", Category = UnitCategory.Volume, + }; + db.UnitsOfMeasure.Add(u); + await db.SaveChangesAsync(); + return u.Id; + }); var list = await CreateListAsync(b => b.WithItem("existing", sortOrder: 5)); var recipe = await Data.CreateRecipeAsync(b => b .Titled("Pancakes").CreatedBy(User) - .WithIngredient("flour", "2 cups", 1) - .WithIngredient("eggs", "2", 2) - .WithIngredient("salt", null, 3)); + .WithIngredient("flour", sortOrder: 1, quantity: 2m, unitOfMeasureId: cupId) + .WithIngredient("eggs", sortOrder: 2, quantity: 2m) + .WithApproximateIngredient("salt", quantityNote: "to taste", sortOrder: 3)); var response = await Client.PostAsync($"/api/lists/{list.Id}/add-recipe/{recipe.Id}", null); @@ -460,7 +471,7 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest await Assert.That(items.Count).IsEqualTo(4); await Assert.That(items.Select(i => i.Name)).IsEquivalentTo(new[] { - "existing", "2 cups flour", "2 eggs", "salt" + "existing", "2 cup flour", "2 eggs", "salt (to taste)" }); await Assert.That(items.Where(i => i.RecipeId == recipe.Id).Count()).IsEqualTo(3); await Assert.That(items[3].SortOrder).IsGreaterThan(5); diff --git a/src/backend/YesChef.Api.IntegrationTests/Features/UnitEndpointsTests.cs b/src/backend/YesChef.Api.IntegrationTests/Features/UnitEndpointsTests.cs index 3c005bf..991031c 100644 --- a/src/backend/YesChef.Api.IntegrationTests/Features/UnitEndpointsTests.cs +++ b/src/backend/YesChef.Api.IntegrationTests/Features/UnitEndpointsTests.cs @@ -197,6 +197,43 @@ public class UnitEndpointsTests : AuthenticatedIntegrationTest await Assert.That(remaining).IsEqualTo(0); } + [Test] + public async Task Delete_family_unit_returns_409_when_referenced_by_recipe_ingredient() + { + var familyId = await GetFamilyIdAsync(); + var unit = await UseDbAsync(async db => + { + var u = new FamilyUnitOfMeasure + { + FamilyId = familyId, + SingularName = "pinch", PluralName = "pinches", + Abbreviation = "pn", Category = UnitCategory.Volume, + }; + db.FamilyUnitsOfMeasure.Add(u); + await db.SaveChangesAsync(); + + var recipe = new Recipe + { + FamilyId = familyId, + Title = "Soup", + CreatedByUserId = User.Id, + Ingredients = new List + { + new() { FamilyId = familyId, Name = "salt", SortOrder = 1, Quantity = 1m, FamilyUnitOfMeasureId = u.Id }, + }, + }; + db.Recipes.Add(recipe); + await db.SaveChangesAsync(); + return u; + }); + + var response = await Client.DeleteAsync($"/api/units/family/{unit.Id}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + var remaining = await UseDbAsync(db => db.FamilyUnitsOfMeasure.CountAsync()); + await Assert.That(remaining).IsEqualTo(1); + } + [Test] public async Task Endpoints_require_authentication() { diff --git a/src/backend/YesChef.Api/Data/YesChefDb.cs b/src/backend/YesChef.Api/Data/YesChefDb.cs index 035a519..3bf6fc5 100644 --- a/src/backend/YesChef.Api/Data/YesChefDb.cs +++ b/src/backend/YesChef.Api/Data/YesChefDb.cs @@ -117,10 +117,16 @@ public class YesChefDb(DbContextOptions options) : DbContext(options) modelBuilder.Entity(e => { e.Property(i => i.Name).HasMaxLength(200); - e.Property(i => i.Quantity).HasMaxLength(50); + e.Property(i => i.Quantity).HasPrecision(12, 4); + e.Property(i => i.QuantityNote).HasMaxLength(200); e.HasOne(i => i.Family).WithMany().HasForeignKey(i => i.FamilyId).OnDelete(DeleteBehavior.Cascade); e.HasOne(i => i.Product).WithMany().HasForeignKey(i => i.ProductId).OnDelete(DeleteBehavior.SetNull); e.HasOne(i => i.FamilyProduct).WithMany().HasForeignKey(i => i.FamilyProductId).OnDelete(DeleteBehavior.SetNull); + // Restrict on delete: the unit-delete endpoint already blocks + // deletion when an ingredient references the unit, so this is a + // defense-in-depth safeguard rather than expected behavior. + e.HasOne(i => i.UnitOfMeasure).WithMany().HasForeignKey(i => i.UnitOfMeasureId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(i => i.FamilyUnitOfMeasure).WithMany().HasForeignKey(i => i.FamilyUnitOfMeasureId).OnDelete(DeleteBehavior.Restrict); }); modelBuilder.Entity(e => diff --git a/src/backend/YesChef.Api/Entities/RecipeIngredient.cs b/src/backend/YesChef.Api/Entities/RecipeIngredient.cs index 2dc50f5..1fb2fe4 100644 --- a/src/backend/YesChef.Api/Entities/RecipeIngredient.cs +++ b/src/backend/YesChef.Api/Entities/RecipeIngredient.cs @@ -8,8 +8,19 @@ public class RecipeIngredient public int RecipeId { get; set; } public Recipe Recipe { get; set; } = null!; public required string Name { get; set; } - public string? Quantity { get; set; } public int SortOrder { get; set; } + // Structured quantity. Null = unspecified ("salt", with no amount). + public decimal? Quantity { get; set; } + // At most one of UnitOfMeasureId / FamilyUnitOfMeasureId is set on a row. + // Both null is permitted (matches Quantity = null). + public int? UnitOfMeasureId { get; set; } + public UnitOfMeasure? UnitOfMeasure { get; set; } + public int? FamilyUnitOfMeasureId { get; set; } + public FamilyUnitOfMeasure? FamilyUnitOfMeasure { get; set; } + // Approximations like "to taste" / "a pinch" — render QuantityNote instead + // of the structured Quantity/Unit pair when this flag is set. + public bool IsApproximate { get; set; } + public string? QuantityNote { get; set; } // At most one of ProductId / FamilyProductId is set; both null = free-form. public int? ProductId { get; set; } public Product? Product { get; set; } diff --git a/src/backend/YesChef.Api/Features/Recipes/RecipeEndpoints.cs b/src/backend/YesChef.Api/Features/Recipes/RecipeEndpoints.cs index f294273..87c18b2 100644 --- a/src/backend/YesChef.Api/Features/Recipes/RecipeEndpoints.cs +++ b/src/backend/YesChef.Api/Features/Recipes/RecipeEndpoints.cs @@ -8,7 +8,17 @@ namespace YesChef.Api.Features.Recipes; public static class RecipeEndpoints { - public record IngredientRequest(string Name, string? Quantity, int SortOrder, int? ProductId = null, int? FamilyProductId = null); + public record IngredientRequest( + string Name, + int SortOrder, + decimal? Quantity = null, + int? UnitOfMeasureId = null, + int? FamilyUnitOfMeasureId = null, + bool IsApproximate = false, + string? QuantityNote = null, + int? ProductId = null, + int? FamilyProductId = null); + public record CreateRecipeRequest(string Title, string? Description, string? Instructions, int? Servings, string? SourceUrl, List Ingredients); public record UpdateRecipeRequest(string Title, string? Description, string? Instructions, int? Servings, string? SourceUrl, List Ingredients); @@ -38,11 +48,8 @@ public static class RecipeEndpoints { var familyId = http.User.GetFamilyId(); - foreach (var ing in request.Ingredients) - { - if (await ShoppingListEndpoints.ValidateProductLink(db, familyId, ing.ProductId, ing.FamilyProductId) is { } error) - return error; - } + if (await ValidateIngredients(db, familyId, request.Ingredients) is { } error) + return error; var recipe = new Recipe { @@ -53,15 +60,7 @@ public static class RecipeEndpoints Servings = request.Servings, SourceUrl = request.SourceUrl, CreatedByUserId = http.User.GetUserId(), - Ingredients = request.Ingredients.Select(i => new RecipeIngredient - { - FamilyId = familyId, - Name = i.Name, - Quantity = i.Quantity, - SortOrder = i.SortOrder, - ProductId = i.ProductId, - FamilyProductId = i.FamilyProductId - }).ToList() + Ingredients = request.Ingredients.Select(i => ToEntity(i, familyId)).ToList() }; db.Recipes.Add(recipe); @@ -90,7 +89,19 @@ public static class RecipeEndpoints recipe.SourceUrl, CreatedBy = recipe.CreatedByUser.Name, recipe.UpdatedAt, - Ingredients = recipe.Ingredients.Select(i => new { i.Id, i.Name, i.Quantity, i.SortOrder, i.ProductId, i.FamilyProductId }) + Ingredients = recipe.Ingredients.Select(i => new + { + i.Id, + i.Name, + i.SortOrder, + i.Quantity, + i.UnitOfMeasureId, + i.FamilyUnitOfMeasureId, + i.IsApproximate, + i.QuantityNote, + i.ProductId, + i.FamilyProductId + }) }); }); @@ -103,11 +114,8 @@ public static class RecipeEndpoints .FirstOrDefaultAsync(); if (recipe is null) return Results.NotFound(); - foreach (var ing in request.Ingredients) - { - if (await ShoppingListEndpoints.ValidateProductLink(db, familyId, ing.ProductId, ing.FamilyProductId) is { } error) - return error; - } + if (await ValidateIngredients(db, familyId, request.Ingredients) is { } error) + return error; recipe.Title = request.Title; recipe.Description = request.Description; @@ -117,15 +125,7 @@ public static class RecipeEndpoints recipe.UpdatedAt = DateTime.UtcNow; db.RecipeIngredients.RemoveRange(recipe.Ingredients); - recipe.Ingredients = request.Ingredients.Select(i => new RecipeIngredient - { - FamilyId = familyId, - Name = i.Name, - Quantity = i.Quantity, - SortOrder = i.SortOrder, - ProductId = i.ProductId, - FamilyProductId = i.FamilyProductId - }).ToList(); + recipe.Ingredients = request.Ingredients.Select(i => ToEntity(i, familyId)).ToList(); await db.SaveChangesAsync(); return Results.Ok(new { recipe.Id, recipe.Title }); @@ -144,4 +144,30 @@ public static class RecipeEndpoints return group; } + + private static async Task ValidateIngredients(YesChefDb db, int familyId, List ingredients) + { + foreach (var ing in ingredients) + { + if (await ShoppingListEndpoints.ValidateProductLink(db, familyId, ing.ProductId, ing.FamilyProductId) is { } pe) + return pe; + if (await ShoppingListEndpoints.ValidateUnitLink(db, familyId, ing.UnitOfMeasureId, ing.FamilyUnitOfMeasureId) is { } ue) + return ue; + } + return null; + } + + private static RecipeIngredient ToEntity(IngredientRequest i, int familyId) => new() + { + FamilyId = familyId, + Name = i.Name, + SortOrder = i.SortOrder, + Quantity = i.Quantity, + UnitOfMeasureId = i.UnitOfMeasureId, + FamilyUnitOfMeasureId = i.FamilyUnitOfMeasureId, + IsApproximate = i.IsApproximate, + QuantityNote = i.QuantityNote, + ProductId = i.ProductId, + FamilyProductId = i.FamilyProductId, + }; } diff --git a/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs b/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs index 47ef38d..1cb1d3b 100644 --- a/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs +++ b/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs @@ -311,7 +311,8 @@ public static class ShoppingListEndpoints var recipe = await db.Recipes .Where(r => r.Id == recipeId && r.FamilyId == familyId) - .Include(r => r.Ingredients) + .Include(r => r.Ingredients).ThenInclude(i => i.UnitOfMeasure) + .Include(r => r.Ingredients).ThenInclude(i => i.FamilyUnitOfMeasure) .FirstOrDefaultAsync(); if (recipe is null) return Results.NotFound(); @@ -329,7 +330,11 @@ public static class ShoppingListEndpoints { FamilyId = familyId, ShoppingListId = listId, - Name = string.IsNullOrEmpty(ing.Quantity) ? ing.Name : $"{ing.Quantity} {ing.Name}", + // Phase 3 will give ShoppingListItem its own structured + // quantity/unit fields. Until then, fold the recipe's + // structured quantity into the free-form Name so the + // list still reads "2 cup flour" / "salt to taste". + Name = FormatIngredientForList(ing), SortOrder = maxSort + idx + 1, RecipeId = recipeId, ProductId = ing.ProductId, @@ -374,6 +379,46 @@ public static class ShoppingListEndpoints return null; } + /// + /// Render an ingredient's structured quantity into a free-form display + /// string for use as a shopping list item Name in Phase 2. Phase 3 will + /// remove this once ShoppingListItem grows its own structured fields. + /// + private static string FormatIngredientForList(RecipeIngredient ing) + { + if (ing.IsApproximate && !string.IsNullOrWhiteSpace(ing.QuantityNote)) + return $"{ing.Name} ({ing.QuantityNote})"; + + var abbrev = ing.UnitOfMeasure?.Abbreviation ?? ing.FamilyUnitOfMeasure?.Abbreviation; + if (ing.Quantity is { } q) + { + // Trim trailing zeros so "2.0000" renders as "2". + var qty = q.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture); + return string.IsNullOrEmpty(abbrev) ? $"{qty} {ing.Name}" : $"{qty} {abbrev} {ing.Name}"; + } + return ing.Name; + } + + /// + /// Validates the unit-of-measure FK pair on an ingredient/item payload. + /// Same shape as : at most one of the + /// two FKs may be set, the global must exist, the family unit must + /// belong to the caller's family. Returns null on success. + /// + internal static async Task ValidateUnitLink(YesChefDb db, int familyId, int? unitOfMeasureId, int? familyUnitOfMeasureId) + { + if (unitOfMeasureId.HasValue && familyUnitOfMeasureId.HasValue) + return Results.BadRequest(new { error = "A quantity can reference a global unit or a family unit, not both." }); + + if (unitOfMeasureId.HasValue && !await db.UnitsOfMeasure.AnyAsync(u => u.Id == unitOfMeasureId.Value)) + return Results.BadRequest(new { error = "Unknown unit of measure." }); + + if (familyUnitOfMeasureId.HasValue && !await db.FamilyUnitsOfMeasure.AnyAsync(u => u.Id == familyUnitOfMeasureId.Value && u.FamilyId == familyId)) + return Results.BadRequest(new { error = "Unknown unit of measure." }); + + return null; + } + /// /// Look up the section a product was last placed in at this store, for /// this family. Returns null if no memory exists or no product link was diff --git a/src/backend/YesChef.Api/Features/Units/UnitEndpoints.cs b/src/backend/YesChef.Api/Features/Units/UnitEndpoints.cs index 0ca1ab5..633ffd6 100644 --- a/src/backend/YesChef.Api/Features/Units/UnitEndpoints.cs +++ b/src/backend/YesChef.Api/Features/Units/UnitEndpoints.cs @@ -119,9 +119,11 @@ public static class UnitEndpoints var unit = await db.FamilyUnitsOfMeasure.FirstOrDefaultAsync(u => u.Id == id && u.FamilyId == familyId); if (unit is null) return Results.NotFound(); - // No usage check yet — RecipeIngredient/ShoppingListItem don't - // reference units in Phase 1. Phase 2/3 will need to block delete - // when rows reference this unit. + // Block deletion when any recipe ingredient still references the + // unit. (ShoppingListItem will gain the same check in Phase 3.) + var inUse = await db.RecipeIngredients.AnyAsync(i => i.FamilyUnitOfMeasureId == id); + if (inUse) return Results.Conflict(new { error = "Unit is in use by one or more recipes." }); + db.FamilyUnitsOfMeasure.Remove(unit); await db.SaveChangesAsync(); return Results.NoContent(); diff --git a/src/backend/YesChef.Api/Migrations/20260513022332_AddStructuredRecipeIngredientQuantities.Designer.cs b/src/backend/YesChef.Api/Migrations/20260513022332_AddStructuredRecipeIngredientQuantities.Designer.cs new file mode 100644 index 0000000..bd96811 --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/20260513022332_AddStructuredRecipeIngredientQuantities.Designer.cs @@ -0,0 +1,1056 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using YesChef.Api.Data; + +#nullable disable + +namespace YesChef.Api.Migrations +{ + [DbContext(typeof(YesChefDb))] + [Migration("20260513022332_AddStructuredRecipeIngredientQuantities")] + partial class AddStructuredRecipeIngredientQuantities + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("YesChef.Api.Entities.Family", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InviteCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("InviteCode") + .IsUnique(); + + b.ToTable("Families"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyMembership", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("UserId", "FamilyId"); + + b.HasIndex("FamilyId"); + + b.ToTable("FamilyMemberships"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyProduct", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Brand") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId", "Name") + .IsUnique(); + + b.ToTable("FamilyProducts"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyProductOverride", b => + { + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Brand") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("FamilyId", "ProductId"); + + b.HasIndex("ProductId"); + + b.ToTable("FamilyProductOverrides"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyUnitOfMeasure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Abbreviation") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("PluralName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SingularName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId", "Abbreviation") + .IsUnique(); + + b.ToTable("FamilyUnitsOfMeasure"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Invite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumedByUserId") + .HasColumnType("integer"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedByUserId") + .HasColumnType("integer"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("ConsumedByUserId"); + + b.HasIndex("IssuedByUserId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("FamilyId", "ConsumedAt"); + + b.ToTable("Invites"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.PasswordResetToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId", "ConsumedAt"); + + b.ToTable("PasswordResetTokens"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Brand") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ProductStoreSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("FamilyProductId") + .HasColumnType("integer"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("StoreId") + .HasColumnType("integer"); + + b.Property("StoreSectionId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FamilyProductId"); + + b.HasIndex("ProductId"); + + b.HasIndex("StoreId"); + + b.HasIndex("StoreSectionId"); + + b.HasIndex("FamilyId", "StoreId", "FamilyProductId") + .IsUnique() + .HasFilter("\"FamilyProductId\" IS NOT NULL"); + + b.HasIndex("FamilyId", "StoreId", "ProductId") + .IsUnique() + .HasFilter("\"ProductId\" IS NOT NULL"); + + b.ToTable("ProductStoreSections"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Instructions") + .HasColumnType("text"); + + b.Property("Servings") + .HasColumnType("integer"); + + b.Property("SourceUrl") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("FamilyId"); + + b.ToTable("Recipes"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("FamilyProductId") + .HasColumnType("integer"); + + b.Property("FamilyUnitOfMeasureId") + .HasColumnType("integer"); + + b.Property("IsApproximate") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Quantity") + .HasPrecision(12, 4) + .HasColumnType("numeric(12,4)"); + + b.Property("QuantityNote") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UnitOfMeasureId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("FamilyProductId"); + + b.HasIndex("FamilyUnitOfMeasureId"); + + b.HasIndex("ProductId"); + + b.HasIndex("RecipeId"); + + b.HasIndex("UnitOfMeasureId"); + + b.ToTable("RecipeIngredients"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StoreId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("FamilyId"); + + b.HasIndex("StoreId"); + + b.ToTable("ShoppingLists"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CheckedByUserId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("FamilyProductId") + .HasColumnType("integer"); + + b.Property("IsChecked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("RemovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RemovedByUserId") + .HasColumnType("integer"); + + b.Property("SectionId") + .HasColumnType("integer"); + + b.Property("ShoppingListId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckedByUserId"); + + b.HasIndex("FamilyId"); + + b.HasIndex("FamilyProductId"); + + b.HasIndex("ProductId"); + + b.HasIndex("RecipeId"); + + b.HasIndex("RemovedByUserId"); + + b.HasIndex("SectionId"); + + b.HasIndex("ShoppingListId"); + + b.ToTable("ShoppingListItems"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Store", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId", "Name") + .IsUnique(); + + b.ToTable("Stores"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.StoreSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StoreId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("StoreId", "Name") + .IsUnique(); + + b.ToTable("StoreSections"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.UnitOfMeasure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Abbreviation") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsBase") + .HasColumnType("boolean"); + + b.Property("PluralName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SingularName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Abbreviation") + .IsUnique(); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("UnitsOfMeasure"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("EmailConfirmedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyMembership", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyProduct", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyProductOverride", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyUnitOfMeasure", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Invite", b => + { + b.HasOne("YesChef.Api.Entities.User", "ConsumedByUser") + .WithMany() + .HasForeignKey("ConsumedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.User", "IssuedByUser") + .WithMany() + .HasForeignKey("IssuedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ConsumedByUser"); + + b.Navigation("Family"); + + b.Navigation("IssuedByUser"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.PasswordResetToken", b => + { + b.HasOne("YesChef.Api.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ProductStoreSection", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.FamilyProduct", "FamilyProduct") + .WithMany() + .HasForeignKey("FamilyProductId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("YesChef.Api.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("YesChef.Api.Entities.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.StoreSection", "StoreSection") + .WithMany() + .HasForeignKey("StoreSectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + b.Navigation("FamilyProduct"); + + b.Navigation("Product"); + + b.Navigation("Store"); + + b.Navigation("StoreSection"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.HasOne("YesChef.Api.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Family"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.FamilyProduct", "FamilyProduct") + .WithMany() + .HasForeignKey("FamilyProductId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.FamilyUnitOfMeasure", "FamilyUnitOfMeasure") + .WithMany() + .HasForeignKey("FamilyUnitOfMeasureId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("YesChef.Api.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.Recipe", "Recipe") + .WithMany("Ingredients") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.UnitOfMeasure", "UnitOfMeasure") + .WithMany() + .HasForeignKey("UnitOfMeasureId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Family"); + + b.Navigation("FamilyProduct"); + + b.Navigation("FamilyUnitOfMeasure"); + + b.Navigation("Product"); + + b.Navigation("Recipe"); + + b.Navigation("UnitOfMeasure"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.HasOne("YesChef.Api.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Family"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingListItem", b => + { + b.HasOne("YesChef.Api.Entities.User", "CheckedByUser") + .WithMany() + .HasForeignKey("CheckedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.FamilyProduct", "FamilyProduct") + .WithMany() + .HasForeignKey("FamilyProductId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.Recipe", "Recipe") + .WithMany() + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.User", "RemovedByUser") + .WithMany() + .HasForeignKey("RemovedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.StoreSection", "Section") + .WithMany() + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.ShoppingList", "ShoppingList") + .WithMany("Items") + .HasForeignKey("ShoppingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CheckedByUser"); + + b.Navigation("Family"); + + b.Navigation("FamilyProduct"); + + b.Navigation("Product"); + + b.Navigation("Recipe"); + + b.Navigation("RemovedByUser"); + + b.Navigation("Section"); + + b.Navigation("ShoppingList"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Store", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.StoreSection", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.Navigation("Ingredients"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/backend/YesChef.Api/Migrations/20260513022332_AddStructuredRecipeIngredientQuantities.cs b/src/backend/YesChef.Api/Migrations/20260513022332_AddStructuredRecipeIngredientQuantities.cs new file mode 100644 index 0000000..206b830 --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/20260513022332_AddStructuredRecipeIngredientQuantities.cs @@ -0,0 +1,129 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace YesChef.Api.Migrations +{ + /// + public partial class AddStructuredRecipeIngredientQuantities : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Quantity is changing from free-form string to structured decimal. + // Per the wipe-to-null migration plan, drop the column outright + // rather than attempting a text→numeric cast that Postgres rejects + // for any non-numeric value. + migrationBuilder.DropColumn( + name: "Quantity", + table: "RecipeIngredients"); + + migrationBuilder.AddColumn( + name: "Quantity", + table: "RecipeIngredients", + type: "numeric(12,4)", + precision: 12, + scale: 4, + nullable: true); + + migrationBuilder.AddColumn( + name: "FamilyUnitOfMeasureId", + table: "RecipeIngredients", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsApproximate", + table: "RecipeIngredients", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "QuantityNote", + table: "RecipeIngredients", + type: "character varying(200)", + maxLength: 200, + nullable: true); + + migrationBuilder.AddColumn( + name: "UnitOfMeasureId", + table: "RecipeIngredients", + type: "integer", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_RecipeIngredients_FamilyUnitOfMeasureId", + table: "RecipeIngredients", + column: "FamilyUnitOfMeasureId"); + + migrationBuilder.CreateIndex( + name: "IX_RecipeIngredients_UnitOfMeasureId", + table: "RecipeIngredients", + column: "UnitOfMeasureId"); + + migrationBuilder.AddForeignKey( + name: "FK_RecipeIngredients_FamilyUnitsOfMeasure_FamilyUnitOfMeasureId", + table: "RecipeIngredients", + column: "FamilyUnitOfMeasureId", + principalTable: "FamilyUnitsOfMeasure", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_RecipeIngredients_UnitsOfMeasure_UnitOfMeasureId", + table: "RecipeIngredients", + column: "UnitOfMeasureId", + principalTable: "UnitsOfMeasure", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_RecipeIngredients_FamilyUnitsOfMeasure_FamilyUnitOfMeasureId", + table: "RecipeIngredients"); + + migrationBuilder.DropForeignKey( + name: "FK_RecipeIngredients_UnitsOfMeasure_UnitOfMeasureId", + table: "RecipeIngredients"); + + migrationBuilder.DropIndex( + name: "IX_RecipeIngredients_FamilyUnitOfMeasureId", + table: "RecipeIngredients"); + + migrationBuilder.DropIndex( + name: "IX_RecipeIngredients_UnitOfMeasureId", + table: "RecipeIngredients"); + + migrationBuilder.DropColumn( + name: "FamilyUnitOfMeasureId", + table: "RecipeIngredients"); + + migrationBuilder.DropColumn( + name: "IsApproximate", + table: "RecipeIngredients"); + + migrationBuilder.DropColumn( + name: "QuantityNote", + table: "RecipeIngredients"); + + migrationBuilder.DropColumn( + name: "UnitOfMeasureId", + table: "RecipeIngredients"); + + migrationBuilder.DropColumn( + name: "Quantity", + table: "RecipeIngredients"); + + migrationBuilder.AddColumn( + name: "Quantity", + table: "RecipeIngredients", + type: "character varying(50)", + maxLength: 50, + nullable: true); + } + } +} diff --git a/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs b/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs index 26ea0f8..f3860a7 100644 --- a/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs +++ b/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs @@ -404,6 +404,12 @@ namespace YesChef.Api.Migrations b.Property("FamilyProductId") .HasColumnType("integer"); + b.Property("FamilyUnitOfMeasureId") + .HasColumnType("integer"); + + b.Property("IsApproximate") + .HasColumnType("boolean"); + b.Property("Name") .IsRequired() .HasMaxLength(200) @@ -412,9 +418,13 @@ namespace YesChef.Api.Migrations b.Property("ProductId") .HasColumnType("integer"); - b.Property("Quantity") - .HasMaxLength(50) - .HasColumnType("character varying(50)"); + b.Property("Quantity") + .HasPrecision(12, 4) + .HasColumnType("numeric(12,4)"); + + b.Property("QuantityNote") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); b.Property("RecipeId") .HasColumnType("integer"); @@ -422,16 +432,23 @@ namespace YesChef.Api.Migrations b.Property("SortOrder") .HasColumnType("integer"); + b.Property("UnitOfMeasureId") + .HasColumnType("integer"); + b.HasKey("Id"); b.HasIndex("FamilyId"); b.HasIndex("FamilyProductId"); + b.HasIndex("FamilyUnitOfMeasureId"); + b.HasIndex("ProductId"); b.HasIndex("RecipeId"); + b.HasIndex("UnitOfMeasureId"); + b.ToTable("RecipeIngredients"); }); @@ -869,6 +886,11 @@ namespace YesChef.Api.Migrations .HasForeignKey("FamilyProductId") .OnDelete(DeleteBehavior.SetNull); + b.HasOne("YesChef.Api.Entities.FamilyUnitOfMeasure", "FamilyUnitOfMeasure") + .WithMany() + .HasForeignKey("FamilyUnitOfMeasureId") + .OnDelete(DeleteBehavior.Restrict); + b.HasOne("YesChef.Api.Entities.Product", "Product") .WithMany() .HasForeignKey("ProductId") @@ -880,13 +902,22 @@ namespace YesChef.Api.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.HasOne("YesChef.Api.Entities.UnitOfMeasure", "UnitOfMeasure") + .WithMany() + .HasForeignKey("UnitOfMeasureId") + .OnDelete(DeleteBehavior.Restrict); + b.Navigation("Family"); b.Navigation("FamilyProduct"); + b.Navigation("FamilyUnitOfMeasure"); + b.Navigation("Product"); b.Navigation("Recipe"); + + b.Navigation("UnitOfMeasure"); }); modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => diff --git a/src/frontend/src/lib/QuantityInput.svelte b/src/frontend/src/lib/QuantityInput.svelte new file mode 100644 index 0000000..b26aec4 --- /dev/null +++ b/src/frontend/src/lib/QuantityInput.svelte @@ -0,0 +1,78 @@ + + + + +
+ + +
diff --git a/src/frontend/src/lib/formatQuantity.ts b/src/frontend/src/lib/formatQuantity.ts new file mode 100644 index 0000000..30ab809 --- /dev/null +++ b/src/frontend/src/lib/formatQuantity.ts @@ -0,0 +1,47 @@ +import type { Unit } from '$lib/units.svelte'; + +export interface QuantityLike { + quantity: number | null; + unitOfMeasureId: number | null; + familyUnitOfMeasureId: number | null; + isApproximate: boolean; + quantityNote: string | null; +} + +/** + * Format a structured quantity for display alongside an ingredient name. + * Returns null when there's nothing to render (no quantity and not approximate). + * + * Examples (when paired with a unit lookup): + * { quantity: 2, unit cup } -> "2 cup" + * { quantity: 1.5, unit lb } -> "1.5 lb" + * { quantity: 3, unit null } -> "3" + * { isApproximate, note "..." } -> "to taste" + */ +export function formatQuantity(q: QuantityLike, units: Unit[]): string | null { + if (q.isApproximate) { + return q.quantityNote?.trim() || null; + } + if (q.quantity === null) return null; + + const unit = lookupUnit(q, units); + const qty = trimTrailingZeros(q.quantity); + return unit ? `${qty} ${unit.abbreviation}` : qty; +} + +function lookupUnit(q: QuantityLike, units: Unit[]): Unit | null { + if (q.unitOfMeasureId !== null) { + return units.find((u) => u.id === q.unitOfMeasureId && u.kind === 'Global') ?? null; + } + if (q.familyUnitOfMeasureId !== null) { + return units.find((u) => u.id === q.familyUnitOfMeasureId && u.kind === 'Family') ?? null; + } + return null; +} + +function trimTrailingZeros(n: number): string { + // "2.0000" → "2", "1.5000" → "1.5". Keeps fractional precision when present. + return n + .toFixed(4) + .replace(/\.?0+$/, ''); +} diff --git a/src/frontend/src/lib/units.svelte.ts b/src/frontend/src/lib/units.svelte.ts new file mode 100644 index 0000000..f28ae16 --- /dev/null +++ b/src/frontend/src/lib/units.svelte.ts @@ -0,0 +1,54 @@ +import { api } from '$lib/api'; + +export type UnitKind = 'Global' | 'Family'; +export type UnitCategory = 'Count' | 'Weight' | 'Volume' | 'Packaging'; + +export interface Unit { + id: number; + kind: UnitKind; + code: string | null; + singularName: string; + pluralName: string; + abbreviation: string; + category: UnitCategory; + isBase: boolean; + sortOrder: number; +} + +// In-memory cache of the effective unit catalog for the current session. +// Single fetch per page-load; refresh on demand via reload(). +let cache = $state(null); +let loading = $state(false); +let error = $state(null); + +export const units = { + get all() { + if (cache === null && !loading) { + void load(); + } + return cache ?? []; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + reload: load, + byId(id: number, kind: UnitKind): Unit | null { + return cache?.find((u) => u.id === id && u.kind === kind) ?? null; + }, +}; + +async function load() { + loading = true; + error = null; + try { + cache = await api('/api/units'); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to load units'; + cache = []; + } finally { + loading = false; + } +} diff --git a/src/frontend/src/routes/recipes/[id]/+page.svelte b/src/frontend/src/routes/recipes/[id]/+page.svelte index 4f23747..830836a 100644 --- a/src/frontend/src/routes/recipes/[id]/+page.svelte +++ b/src/frontend/src/routes/recipes/[id]/+page.svelte @@ -3,12 +3,18 @@ import { page } from '$app/state'; import { goto } from '$app/navigation'; import { api } from '$lib/api'; + import { units } from '$lib/units.svelte'; + import { formatQuantity } from '$lib/formatQuantity'; interface Ingredient { id: number; name: string; - quantity: string | null; sortOrder: number; + quantity: number | null; + unitOfMeasureId: number | null; + familyUnitOfMeasureId: number | null; + isApproximate: boolean; + quantityNote: string | null; } interface Recipe { @@ -41,6 +47,8 @@ api(`/api/recipes/${recipeId}`), api('/api/lists') ]); + // Touch the units store to kick off the catalog fetch. + void units.all; loading = false; }); @@ -125,9 +133,10 @@

Ingredients

    {#each recipe.ingredients as ingredient} + {@const display = formatQuantity(ingredient, units.all)}
  • - {#if ingredient.quantity} - {ingredient.quantity} + {#if display} + {display} {/if} {ingredient.name}
  • diff --git a/src/frontend/src/routes/recipes/new/+page.svelte b/src/frontend/src/routes/recipes/new/+page.svelte index bcbfda8..5046907 100644 --- a/src/frontend/src/routes/recipes/new/+page.svelte +++ b/src/frontend/src/routes/recipes/new/+page.svelte @@ -2,10 +2,13 @@ import { goto } from '$app/navigation'; import { api } from '$lib/api'; import ProductTypeahead, { type ProductSuggestion } from '$lib/ProductTypeahead.svelte'; + import QuantityInput, { type QuantityValue } from '$lib/QuantityInput.svelte'; interface IngredientForm { name: string; - quantity: string; + quantity: QuantityValue; + isApproximate: boolean; + quantityNote: string; productId: number | null; familyProductId: number | null; } @@ -18,7 +21,14 @@ let saving = $state(false); function emptyIngredient(): IngredientForm { - return { name: '', quantity: '', productId: null, familyProductId: null }; + return { + name: '', + quantity: { quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null }, + isApproximate: false, + quantityNote: '', + productId: null, + familyProductId: null, + }; } function addIngredient() { @@ -29,6 +39,16 @@ ingredients = ingredients.filter((_, i) => i !== idx); } + function toggleApproximate(idx: number) { + const ing = ingredients[idx]; + ing.isApproximate = !ing.isApproximate; + if (ing.isApproximate) { + ing.quantity = { quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null }; + } else { + ing.quantityNote = ''; + } + } + function onIngredientProductChange(idx: number, product: ProductSuggestion | null) { const next = ingredients[idx]; if (product === null) { @@ -59,8 +79,12 @@ .filter((i) => i.name.trim()) .map((i, idx) => ({ name: i.name, - quantity: i.quantity || null, sortOrder: idx, + quantity: i.isApproximate ? null : i.quantity.quantity, + unitOfMeasureId: i.isApproximate ? null : i.quantity.unitOfMeasureId, + familyUnitOfMeasureId: i.isApproximate ? null : i.quantity.familyUnitOfMeasureId, + isApproximate: i.isApproximate, + quantityNote: i.isApproximate ? (i.quantityNote || null) : null, productId: i.productId, familyProductId: i.familyProductId })) @@ -109,31 +133,45 @@
    Ingredients {#each ingredients as ingredient, idx} -
    - -
    - onIngredientProductChange(idx, p)} - /> +
    +
    + {#if ingredient.isApproximate} + + {:else} + + {/if} +
    + onIngredientProductChange(idx, p)} + /> +
    + {#if ingredients.length > 1} + + {/if}
    - {#if ingredients.length > 1} - - {/if} +
    {/each}