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) <noreply@anthropic.com>
This commit is contained in:
Josh Rogers
2026-05-12 21:36:25 -05:00
parent 559d80c104
commit c7f9c31952
17 changed files with 1805 additions and 89 deletions
@@ -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;
}
@@ -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<JsonElement>($"/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<JsonElement>($"/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");
}
}
@@ -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);
@@ -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<RecipeIngredient>
{
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()
{