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:
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
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<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();
|
||||
|
||||
+1056
File diff suppressed because it is too large
Load Diff
+129
@@ -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 =>
|
||||
|
||||
@@ -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>
|
||||
@@ -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+$/, '');
|
||||
}
|
||||
@@ -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,31 +133,45 @@
|
||||
<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">
|
||||
<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"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<ProductTypeahead
|
||||
bind:value={ingredient.name}
|
||||
placeholder="Ingredient name"
|
||||
ariaLabel="Ingredient name"
|
||||
inputClass="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
onProductChange={(p) => onIngredientProductChange(idx, p)}
|
||||
/>
|
||||
<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.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}
|
||||
placeholder="Ingredient name"
|
||||
ariaLabel="Ingredient name"
|
||||
inputClass="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
onProductChange={(p) => onIngredientProductChange(idx, p)}
|
||||
/>
|
||||
</div>
|
||||
{#if ingredients.length > 1}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeIngredient(idx)}
|
||||
class="px-2 text-gray-300 active:text-danger"
|
||||
aria-label="Remove ingredient"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if ingredients.length > 1}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeIngredient(idx)}
|
||||
class="px-2 text-gray-300 active:text-danger"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{/if}
|
||||
<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
|
||||
|
||||
Reference in New Issue
Block a user