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()
{
+7 -1
View File
@@ -117,10 +117,16 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
modelBuilder.Entity<RecipeIngredient>(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<Product>(e =>
@@ -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; }
@@ -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<IngredientRequest> Ingredients);
public record UpdateRecipeRequest(string Title, string? Description, string? Instructions, int? Servings, string? SourceUrl, List<IngredientRequest> 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)
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)
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<IResult?> ValidateIngredients(YesChefDb db, int familyId, List<IngredientRequest> 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,
};
}
@@ -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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Validates the unit-of-measure FK pair on an ingredient/item payload.
/// Same shape as <see cref="ValidateProductLink"/>: 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.
/// </summary>
internal static async Task<IResult?> 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;
}
/// <summary>
/// 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
@@ -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();
@@ -0,0 +1,129 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace YesChef.Api.Migrations
{
/// <inheritdoc />
public partial class AddStructuredRecipeIngredientQuantities : Migration
{
/// <inheritdoc />
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<decimal>(
name: "Quantity",
table: "RecipeIngredients",
type: "numeric(12,4)",
precision: 12,
scale: 4,
nullable: true);
migrationBuilder.AddColumn<int>(
name: "FamilyUnitOfMeasureId",
table: "RecipeIngredients",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsApproximate",
table: "RecipeIngredients",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "QuantityNote",
table: "RecipeIngredients",
type: "character varying(200)",
maxLength: 200,
nullable: true);
migrationBuilder.AddColumn<int>(
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);
}
/// <inheritdoc />
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<string>(
name: "Quantity",
table: "RecipeIngredients",
type: "character varying(50)",
maxLength: 50,
nullable: true);
}
}
}
@@ -404,6 +404,12 @@ namespace YesChef.Api.Migrations
b.Property<int?>("FamilyProductId")
.HasColumnType("integer");
b.Property<int?>("FamilyUnitOfMeasureId")
.HasColumnType("integer");
b.Property<bool>("IsApproximate")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
@@ -412,9 +418,13 @@ namespace YesChef.Api.Migrations
b.Property<int?>("ProductId")
.HasColumnType("integer");
b.Property<string>("Quantity")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<decimal?>("Quantity")
.HasPrecision(12, 4)
.HasColumnType("numeric(12,4)");
b.Property<string>("QuantityNote")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("RecipeId")
.HasColumnType("integer");
@@ -422,16 +432,23 @@ namespace YesChef.Api.Migrations
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<int?>("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 =>
+78
View File
@@ -0,0 +1,78 @@
<script lang="ts" module>
export interface QuantityValue {
quantity: number | null;
unitOfMeasureId: number | null;
familyUnitOfMeasureId: number | null;
}
</script>
<script lang="ts">
import { units } from '$lib/units.svelte';
interface Props {
value: QuantityValue;
ariaLabel?: string;
}
let { value = $bindable(), ariaLabel = 'Quantity' }: Props = $props();
// The unit dropdown carries a composite id "kind:id" so we can route to
// the right FK column on the server. "" = no unit selected.
const composite = $derived.by(() => {
if (value.unitOfMeasureId !== null) return `Global:${value.unitOfMeasureId}`;
if (value.familyUnitOfMeasureId !== null) return `Family:${value.familyUnitOfMeasureId}`;
return '';
});
function onUnitChange(e: Event) {
const raw = (e.currentTarget as HTMLSelectElement).value;
if (raw === '') {
value.unitOfMeasureId = null;
value.familyUnitOfMeasureId = null;
return;
}
const [kind, idStr] = raw.split(':');
const id = Number(idStr);
if (kind === 'Global') {
value.unitOfMeasureId = id;
value.familyUnitOfMeasureId = null;
} else {
value.unitOfMeasureId = null;
value.familyUnitOfMeasureId = id;
}
}
function onQuantityInput(e: Event) {
const raw = (e.currentTarget as HTMLInputElement).value;
value.quantity = raw === '' ? null : Number(raw);
}
const sorted = $derived(
[...units.all].sort((a, b) => a.sortOrder - b.sortOrder || a.singularName.localeCompare(b.singularName))
);
</script>
<div class="flex gap-1">
<input
type="number"
step="0.01"
min="0"
inputmode="decimal"
value={value.quantity ?? ''}
oninput={onQuantityInput}
placeholder="Qty"
aria-label={ariaLabel}
class="w-20 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none"
/>
<select
value={composite}
onchange={onUnitChange}
aria-label="Unit"
class="w-24 rounded-lg border border-gray-300 px-1 py-2 text-sm focus:border-primary focus:outline-none"
>
<option value="">unit</option>
{#each sorted as unit}
<option value="{unit.kind}:{unit.id}">{unit.abbreviation}</option>
{/each}
</select>
</div>
+47
View File
@@ -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+$/, '');
}
+54
View File
@@ -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<Unit[] | null>(null);
let loading = $state(false);
let error = $state<string | null>(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<Unit[]>('/api/units');
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load units';
cache = [];
} finally {
loading = false;
}
}
@@ -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<Recipe>(`/api/recipes/${recipeId}`),
api<ListSummary[]>('/api/lists')
]);
// Touch the units store to kick off the catalog fetch.
void units.all;
loading = false;
});
@@ -125,9 +133,10 @@
<h3 class="mb-2 text-lg font-semibold">Ingredients</h3>
<ul class="space-y-1.5">
{#each recipe.ingredients as ingredient}
{@const display = formatQuantity(ingredient, units.all)}
<li class="flex gap-2 rounded-lg bg-white px-3 py-2 shadow-sm">
{#if ingredient.quantity}
<span class="font-medium text-primary">{ingredient.quantity}</span>
{#if display}
<span class="font-medium text-primary">{display}</span>
{/if}
<span>{ingredient.name}</span>
</li>
@@ -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,13 +133,18 @@
<div>
<span class="mb-2 block text-sm font-medium text-gray-600">Ingredients</span>
{#each ingredients as ingredient, idx}
<div class="mb-2 flex gap-2">
<div class="mb-3 space-y-1 rounded-lg border border-gray-100 p-2">
<div class="flex gap-2">
{#if ingredient.isApproximate}
<input
type="text"
bind:value={ingredient.quantity}
placeholder="Qty"
class="w-20 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none"
bind:value={ingredient.quantityNote}
placeholder="e.g. to taste"
class="w-44 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none"
/>
{:else}
<QuantityInput bind:value={ingredient.quantity} />
{/if}
<div class="flex-1">
<ProductTypeahead
bind:value={ingredient.name}
@@ -130,11 +159,20 @@
type="button"
onclick={() => removeIngredient(idx)}
class="px-2 text-gray-300 active:text-danger"
aria-label="Remove ingredient"
>
</button>
{/if}
</div>
<button
type="button"
onclick={() => toggleApproximate(idx)}
class="text-xs text-gray-500 hover:text-primary"
>
{ingredient.isApproximate ? '↩ use measured amount' : '~ approximate (e.g. to taste)'}
</button>
</div>
{/each}
<button
type="button"