Add structured quantities + units to shopping list items

Mirrors the Phase 2 work on RecipeIngredient: ShoppingListItem grows
Quantity (decimal), UnitOfMeasureId / FamilyUnitOfMeasureId, IsApproximate,
and QuantityNote. The recipe-to-list copy now carries structured fields
verbatim instead of folding them into the free-form Name, and the
unit-in-use guard now also blocks deleting a family unit that's referenced
by a shopping list item.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Josh Rogers
2026-05-13 21:18:26 -05:00
parent c7f9c31952
commit fb1bc2b7e1
11 changed files with 1606 additions and 64 deletions
@@ -21,9 +21,35 @@ public sealed class ShoppingListBuilder
public ShoppingListBuilder ForFamily(Family family) { _familyId = family.Id; return this; } public ShoppingListBuilder ForFamily(Family family) { _familyId = family.Id; return this; }
public ShoppingListBuilder Archived() { _archived = true; return this; } public ShoppingListBuilder Archived() { _archived = true; return this; }
public ShoppingListBuilder WithItem(string name, bool isChecked = false, int sortOrder = 0) public ShoppingListBuilder WithItem(
string name,
bool isChecked = false,
int sortOrder = 0,
decimal? quantity = null,
int? unitOfMeasureId = null,
int? familyUnitOfMeasureId = null)
{ {
_items.Add(new ShoppingListItem { Name = name, IsChecked = isChecked, SortOrder = sortOrder }); _items.Add(new ShoppingListItem
{
Name = name,
IsChecked = isChecked,
SortOrder = sortOrder,
Quantity = quantity,
UnitOfMeasureId = unitOfMeasureId,
FamilyUnitOfMeasureId = familyUnitOfMeasureId,
});
return this;
}
public ShoppingListBuilder WithApproximateItem(string name, string quantityNote, int sortOrder = 0)
{
_items.Add(new ShoppingListItem
{
Name = name,
SortOrder = sortOrder,
IsApproximate = true,
QuantityNote = quantityNote,
});
return this; return this;
} }
@@ -441,7 +441,7 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
} }
[Test] [Test]
public async Task Add_recipe_appends_all_ingredients_with_quantity_prefix() public async Task Add_recipe_copies_structured_quantity_to_items()
{ {
var cupId = await UseDbAsync(async db => var cupId = await UseDbAsync(async db =>
{ {
@@ -471,10 +471,134 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
await Assert.That(items.Count).IsEqualTo(4); await Assert.That(items.Count).IsEqualTo(4);
await Assert.That(items.Select(i => i.Name)).IsEquivalentTo(new[] await Assert.That(items.Select(i => i.Name)).IsEquivalentTo(new[]
{ {
"existing", "2 cup flour", "2 eggs", "salt (to taste)" "existing", "flour", "eggs", "salt"
}); });
await Assert.That(items.Where(i => i.RecipeId == recipe.Id).Count()).IsEqualTo(3); await Assert.That(items.Where(i => i.RecipeId == recipe.Id).Count()).IsEqualTo(3);
await Assert.That(items[3].SortOrder).IsGreaterThan(5);
var flour = items.Single(i => i.Name == "flour");
await Assert.That(flour.Quantity).IsEqualTo(2m);
await Assert.That(flour.UnitOfMeasureId).IsEqualTo(cupId);
await Assert.That(flour.IsApproximate).IsFalse();
var eggs = items.Single(i => i.Name == "eggs");
await Assert.That(eggs.Quantity).IsEqualTo(2m);
await Assert.That(eggs.UnitOfMeasureId).IsNull();
var salt = items.Single(i => i.Name == "salt");
await Assert.That(salt.IsApproximate).IsTrue();
await Assert.That(salt.QuantityNote).IsEqualTo("to taste");
await Assert.That(salt.Quantity).IsNull();
}
[Test]
public async Task Add_item_persists_structured_quantity()
{
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();
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("flour", Quantity: 2.5m, UnitOfMeasureId: cupId));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync());
await Assert.That(item.Quantity).IsEqualTo(2.5m);
await Assert.That(item.UnitOfMeasureId).IsEqualTo(cupId);
await Assert.That(item.IsApproximate).IsFalse();
}
[Test]
public async Task Add_item_persists_approximate_note_and_clears_structured_fields()
{
var list = await CreateListAsync();
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("salt", Quantity: 1m, IsApproximate: true, QuantityNote: "to taste"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync());
await Assert.That(item.IsApproximate).IsTrue();
await Assert.That(item.QuantityNote).IsEqualTo("to taste");
await Assert.That(item.Quantity).IsNull();
await Assert.That(item.UnitOfMeasureId).IsNull();
}
[Test]
public async Task Add_item_rejects_setting_both_unit_ids()
{
var list = await CreateListAsync();
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("flour", UnitOfMeasureId: 1, FamilyUnitOfMeasureId: 1));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
[Test]
public async Task Add_item_rejects_other_familys_family_unit()
{
var list = await CreateListAsync();
var foreignUnitId = await UseDbAsync(async db =>
{
var otherFamily = new Family { Name = "Other-Unit", InviteCode = "other-unit-code" };
db.Families.Add(otherFamily);
await db.SaveChangesAsync();
var u = new FamilyUnitOfMeasure
{
FamilyId = otherFamily.Id,
SingularName = "scoop", PluralName = "scoops",
Abbreviation = "scp", Category = UnitCategory.Count,
};
db.FamilyUnitsOfMeasure.Add(u);
await db.SaveChangesAsync();
return u.Id;
});
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("flour", FamilyUnitOfMeasureId: foreignUnitId));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
[Test]
public async Task Get_list_projects_structured_quantity_fields()
{
var cupId = await UseDbAsync(async db =>
{
var u = new UnitOfMeasure
{
Code = "cup-proj", SingularName = "cup", PluralName = "cups",
Abbreviation = "cup-proj", Category = UnitCategory.Volume,
};
db.UnitsOfMeasure.Add(u);
await db.SaveChangesAsync();
return u.Id;
});
var list = await CreateListAsync(b => b
.WithItem("flour", quantity: 3m, unitOfMeasureId: cupId)
.WithApproximateItem("salt", "to taste", sortOrder: 2));
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/lists/{list.Id}");
var items = body.GetProperty("items").EnumerateArray().ToArray();
var flour = items.Single(i => i.GetProperty("name").GetString() == "flour");
await Assert.That(flour.GetProperty("quantity").GetDecimal()).IsEqualTo(3m);
await Assert.That(flour.GetProperty("unitOfMeasureId").GetInt32()).IsEqualTo(cupId);
await Assert.That(flour.GetProperty("isApproximate").GetBoolean()).IsFalse();
var salt = items.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");
await Assert.That(salt.GetProperty("quantity").ValueKind).IsEqualTo(JsonValueKind.Null);
} }
[Test] [Test]
@@ -234,6 +234,43 @@ public class UnitEndpointsTests : AuthenticatedIntegrationTest
await Assert.That(remaining).IsEqualTo(1); await Assert.That(remaining).IsEqualTo(1);
} }
[Test]
public async Task Delete_family_unit_returns_409_when_referenced_by_shopping_list_item()
{
var familyId = await GetFamilyIdAsync();
var store = await Data.CreateStoreAsync();
var unit = await UseDbAsync(async db =>
{
var u = new FamilyUnitOfMeasure
{
FamilyId = familyId,
SingularName = "scoop", PluralName = "scoops",
Abbreviation = "scp", Category = UnitCategory.Count,
};
db.FamilyUnitsOfMeasure.Add(u);
await db.SaveChangesAsync();
var list = new ShoppingList
{
FamilyId = familyId,
Name = "List with unit",
StoreId = store.Id,
CreatedByUserId = User.Id,
Items = new List<ShoppingListItem>
{
new() { FamilyId = familyId, Name = "ice cream", Quantity = 2m, FamilyUnitOfMeasureId = u.Id },
},
};
db.ShoppingLists.Add(list);
await db.SaveChangesAsync();
return u;
});
var response = await Client.DeleteAsync($"/api/units/family/{unit.Id}");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
}
[Test] [Test]
public async Task Endpoints_require_authentication() public async Task Endpoints_require_authentication()
{ {
@@ -95,6 +95,8 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
modelBuilder.Entity<ShoppingListItem>(e => modelBuilder.Entity<ShoppingListItem>(e =>
{ {
e.Property(i => i.Name).HasMaxLength(300); e.Property(i => i.Name).HasMaxLength(300);
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.Family).WithMany().HasForeignKey(i => i.FamilyId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(i => i.CheckedByUser).WithMany().HasForeignKey(i => i.CheckedByUserId).OnDelete(DeleteBehavior.SetNull); e.HasOne(i => i.CheckedByUser).WithMany().HasForeignKey(i => i.CheckedByUserId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(i => i.RemovedByUser).WithMany().HasForeignKey(i => i.RemovedByUserId).OnDelete(DeleteBehavior.SetNull); e.HasOne(i => i.RemovedByUser).WithMany().HasForeignKey(i => i.RemovedByUserId).OnDelete(DeleteBehavior.SetNull);
@@ -102,6 +104,10 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
e.HasOne(i => i.Section).WithMany().HasForeignKey(i => i.SectionId).OnDelete(DeleteBehavior.SetNull); e.HasOne(i => i.Section).WithMany().HasForeignKey(i => i.SectionId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(i => i.Product).WithMany().HasForeignKey(i => i.ProductId).OnDelete(DeleteBehavior.SetNull); 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); 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 item references the unit; this is defense in depth.
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);
e.HasIndex(i => i.FamilyId); e.HasIndex(i => i.FamilyId);
}); });
@@ -22,6 +22,17 @@ public class ShoppingListItem
public Product? Product { get; set; } public Product? Product { get; set; }
public int? FamilyProductId { get; set; } public int? FamilyProductId { get; set; }
public FamilyProduct? FamilyProduct { get; set; } public FamilyProduct? FamilyProduct { get; set; }
// Structured quantity. Null = unspecified (free-form Name carries the meaning).
public decimal? Quantity { get; set; }
// At most one of UnitOfMeasureId / FamilyUnitOfMeasureId is set on a row.
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; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? RemovedAt { get; set; } public DateTime? RemovedAt { get; set; }
public int? RemovedByUserId { get; set; } public int? RemovedByUserId { get; set; }
@@ -10,7 +10,17 @@ public static class ShoppingListEndpoints
{ {
public record CreateListRequest(string Name, int StoreId); public record CreateListRequest(string Name, int StoreId);
public record UpdateListRequest(string Name, int StoreId); public record UpdateListRequest(string Name, int StoreId);
public record AddItemRequest(string Name, int SortOrder = 0, int? SectionId = null, int? ProductId = null, int? FamilyProductId = null); public record AddItemRequest(
string Name,
int SortOrder = 0,
int? SectionId = null,
int? ProductId = null,
int? FamilyProductId = null,
decimal? Quantity = null,
int? UnitOfMeasureId = null,
int? FamilyUnitOfMeasureId = null,
bool IsApproximate = false,
string? QuantityNote = null);
public record SetItemSectionRequest(int? SectionId); public record SetItemSectionRequest(int? SectionId);
private static string OverviewGroup(int familyId) => $"lists-overview-{familyId}"; private static string OverviewGroup(int familyId) => $"lists-overview-{familyId}";
@@ -129,7 +139,12 @@ public static class ShoppingListEndpoints
i.SectionId, i.SectionId,
RecipeTitle = i.Recipe?.Title, RecipeTitle = i.Recipe?.Title,
i.ProductId, i.ProductId,
i.FamilyProductId i.FamilyProductId,
i.Quantity,
i.UnitOfMeasureId,
i.FamilyUnitOfMeasureId,
i.IsApproximate,
i.QuantityNote
}) })
}); });
}); });
@@ -183,6 +198,9 @@ public static class ShoppingListEndpoints
if (await ValidateProductLink(db, familyId, request.ProductId, request.FamilyProductId) is { } productError) if (await ValidateProductLink(db, familyId, request.ProductId, request.FamilyProductId) is { } productError)
return productError; return productError;
if (await ValidateUnitLink(db, familyId, request.UnitOfMeasureId, request.FamilyUnitOfMeasureId) is { } unitError)
return unitError;
// Auto-assign a section from memory when caller didn't pick one // Auto-assign a section from memory when caller didn't pick one
// but supplied a product link — "we put bananas in Produce last // but supplied a product link — "we put bananas in Produce last
// time we shopped here, do it again." // time we shopped here, do it again."
@@ -198,6 +216,11 @@ public static class ShoppingListEndpoints
SectionId = resolvedSectionId, SectionId = resolvedSectionId,
ProductId = request.ProductId, ProductId = request.ProductId,
FamilyProductId = request.FamilyProductId, FamilyProductId = request.FamilyProductId,
Quantity = request.IsApproximate ? null : request.Quantity,
UnitOfMeasureId = request.IsApproximate ? null : request.UnitOfMeasureId,
FamilyUnitOfMeasureId = request.IsApproximate ? null : request.FamilyUnitOfMeasureId,
IsApproximate = request.IsApproximate,
QuantityNote = request.IsApproximate ? request.QuantityNote : null,
}; };
db.ShoppingListItems.Add(item); db.ShoppingListItems.Add(item);
list.UpdatedAt = DateTime.UtcNow; list.UpdatedAt = DateTime.UtcNow;
@@ -210,9 +233,9 @@ public static class ShoppingListEndpoints
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", new { item.Id, item.Name, item.SortOrder, item.SectionId, item.ProductId, item.FamilyProductId }); await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", ItemAddedPayload(item, recipeTitle: null));
await BroadcastListSummary(hub, db, listId, familyId); await BroadcastListSummary(hub, db, listId, familyId);
return Results.Created($"/api/lists/{listId}/items/{item.Id}", new { item.Id, item.Name, item.SortOrder, item.SectionId, item.ProductId, item.FamilyProductId }); return Results.Created($"/api/lists/{listId}/items/{item.Id}", ItemAddedPayload(item, recipeTitle: null));
}); });
group.MapPatch("/{listId:int}/items/{itemId:int}/section", async (int listId, int itemId, SetItemSectionRequest request, YesChefDb db, HttpContext http, IHubContext<ShoppingListHub> hub) => group.MapPatch("/{listId:int}/items/{itemId:int}/section", async (int listId, int itemId, SetItemSectionRequest request, YesChefDb db, HttpContext http, IHubContext<ShoppingListHub> hub) =>
@@ -297,7 +320,12 @@ public static class ShoppingListEndpoints
item.SectionId, item.SectionId,
RecipeTitle = item.Recipe?.Title, RecipeTitle = item.Recipe?.Title,
item.ProductId, item.ProductId,
item.FamilyProductId item.FamilyProductId,
item.Quantity,
item.UnitOfMeasureId,
item.FamilyUnitOfMeasureId,
item.IsApproximate,
item.QuantityNote
}); });
await BroadcastListSummary(hub, db, listId, familyId); await BroadcastListSummary(hub, db, listId, familyId);
return Results.NoContent(); return Results.NoContent();
@@ -330,16 +358,17 @@ public static class ShoppingListEndpoints
{ {
FamilyId = familyId, FamilyId = familyId,
ShoppingListId = listId, ShoppingListId = listId,
// Phase 3 will give ShoppingListItem its own structured Name = ing.Name,
// 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, SortOrder = maxSort + idx + 1,
RecipeId = recipeId, RecipeId = recipeId,
ProductId = ing.ProductId, ProductId = ing.ProductId,
FamilyProductId = ing.FamilyProductId, FamilyProductId = ing.FamilyProductId,
SectionId = rememberedSectionId, SectionId = rememberedSectionId,
Quantity = ing.Quantity,
UnitOfMeasureId = ing.UnitOfMeasureId,
FamilyUnitOfMeasureId = ing.FamilyUnitOfMeasureId,
IsApproximate = ing.IsApproximate,
QuantityNote = ing.QuantityNote,
}); });
idx++; idx++;
} }
@@ -350,7 +379,7 @@ public static class ShoppingListEndpoints
foreach (var item in newItems) foreach (var item in newItems)
{ {
await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", new { item.Id, item.Name, item.SortOrder, item.SectionId, item.ProductId, item.FamilyProductId, RecipeTitle = recipe.Title }); await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", ItemAddedPayload(item, recipe.Title));
} }
await BroadcastListSummary(hub, db, listId, familyId); await BroadcastListSummary(hub, db, listId, familyId);
@@ -380,24 +409,25 @@ public static class ShoppingListEndpoints
} }
/// <summary> /// <summary>
/// Render an ingredient's structured quantity into a free-form display /// Shape the wire payload for ItemAdded / Created responses so clients
/// string for use as a shopping list item Name in Phase 2. Phase 3 will /// can render the structured quantity. Single source of truth so the
/// remove this once ShoppingListItem grows its own structured fields. /// list endpoint and recipe-add fan-out stay aligned.
/// </summary> /// </summary>
private static string FormatIngredientForList(RecipeIngredient ing) private static object ItemAddedPayload(ShoppingListItem item, string? recipeTitle) => new
{ {
if (ing.IsApproximate && !string.IsNullOrWhiteSpace(ing.QuantityNote)) item.Id,
return $"{ing.Name} ({ing.QuantityNote})"; item.Name,
item.SortOrder,
var abbrev = ing.UnitOfMeasure?.Abbreviation ?? ing.FamilyUnitOfMeasure?.Abbreviation; item.SectionId,
if (ing.Quantity is { } q) item.ProductId,
{ item.FamilyProductId,
// Trim trailing zeros so "2.0000" renders as "2". item.Quantity,
var qty = q.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture); item.UnitOfMeasureId,
return string.IsNullOrEmpty(abbrev) ? $"{qty} {ing.Name}" : $"{qty} {abbrev} {ing.Name}"; item.FamilyUnitOfMeasureId,
} item.IsApproximate,
return ing.Name; item.QuantityNote,
} RecipeTitle = recipeTitle,
};
/// <summary> /// <summary>
/// Validates the unit-of-measure FK pair on an ingredient/item payload. /// Validates the unit-of-measure FK pair on an ingredient/item payload.
@@ -119,10 +119,12 @@ public static class UnitEndpoints
var unit = await db.FamilyUnitsOfMeasure.FirstOrDefaultAsync(u => u.Id == id && u.FamilyId == familyId); var unit = await db.FamilyUnitsOfMeasure.FirstOrDefaultAsync(u => u.Id == id && u.FamilyId == familyId);
if (unit is null) return Results.NotFound(); if (unit is null) return Results.NotFound();
// Block deletion when any recipe ingredient still references the // Block deletion when any recipe ingredient or shopping list item
// unit. (ShoppingListItem will gain the same check in Phase 3.) // still references the unit.
var inUse = await db.RecipeIngredients.AnyAsync(i => i.FamilyUnitOfMeasureId == id); var inUseByRecipe = await db.RecipeIngredients.AnyAsync(i => i.FamilyUnitOfMeasureId == id);
if (inUse) return Results.Conflict(new { error = "Unit is in use by one or more recipes." }); if (inUseByRecipe) return Results.Conflict(new { error = "Unit is in use by one or more recipes." });
var inUseByList = await db.ShoppingListItems.AnyAsync(i => i.FamilyUnitOfMeasureId == id);
if (inUseByList) return Results.Conflict(new { error = "Unit is in use by one or more shopping lists." });
db.FamilyUnitsOfMeasure.Remove(unit); db.FamilyUnitsOfMeasure.Remove(unit);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -0,0 +1,114 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace YesChef.Api.Migrations
{
/// <inheritdoc />
public partial class AddStructuredShoppingListItemQuantities : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "FamilyUnitOfMeasureId",
table: "ShoppingListItems",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsApproximate",
table: "ShoppingListItems",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<decimal>(
name: "Quantity",
table: "ShoppingListItems",
type: "numeric(12,4)",
precision: 12,
scale: 4,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "QuantityNote",
table: "ShoppingListItems",
type: "character varying(200)",
maxLength: 200,
nullable: true);
migrationBuilder.AddColumn<int>(
name: "UnitOfMeasureId",
table: "ShoppingListItems",
type: "integer",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_ShoppingListItems_FamilyUnitOfMeasureId",
table: "ShoppingListItems",
column: "FamilyUnitOfMeasureId");
migrationBuilder.CreateIndex(
name: "IX_ShoppingListItems_UnitOfMeasureId",
table: "ShoppingListItems",
column: "UnitOfMeasureId");
migrationBuilder.AddForeignKey(
name: "FK_ShoppingListItems_FamilyUnitsOfMeasure_FamilyUnitOfMeasureId",
table: "ShoppingListItems",
column: "FamilyUnitOfMeasureId",
principalTable: "FamilyUnitsOfMeasure",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_ShoppingListItems_UnitsOfMeasure_UnitOfMeasureId",
table: "ShoppingListItems",
column: "UnitOfMeasureId",
principalTable: "UnitsOfMeasure",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_ShoppingListItems_FamilyUnitsOfMeasure_FamilyUnitOfMeasureId",
table: "ShoppingListItems");
migrationBuilder.DropForeignKey(
name: "FK_ShoppingListItems_UnitsOfMeasure_UnitOfMeasureId",
table: "ShoppingListItems");
migrationBuilder.DropIndex(
name: "IX_ShoppingListItems_FamilyUnitOfMeasureId",
table: "ShoppingListItems");
migrationBuilder.DropIndex(
name: "IX_ShoppingListItems_UnitOfMeasureId",
table: "ShoppingListItems");
migrationBuilder.DropColumn(
name: "FamilyUnitOfMeasureId",
table: "ShoppingListItems");
migrationBuilder.DropColumn(
name: "IsApproximate",
table: "ShoppingListItems");
migrationBuilder.DropColumn(
name: "Quantity",
table: "ShoppingListItems");
migrationBuilder.DropColumn(
name: "QuantityNote",
table: "ShoppingListItems");
migrationBuilder.DropColumn(
name: "UnitOfMeasureId",
table: "ShoppingListItems");
}
}
}
@@ -514,6 +514,12 @@ namespace YesChef.Api.Migrations
b.Property<int?>("FamilyProductId") b.Property<int?>("FamilyProductId")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<int?>("FamilyUnitOfMeasureId")
.HasColumnType("integer");
b.Property<bool>("IsApproximate")
.HasColumnType("boolean");
b.Property<bool>("IsChecked") b.Property<bool>("IsChecked")
.HasColumnType("boolean"); .HasColumnType("boolean");
@@ -525,6 +531,14 @@ namespace YesChef.Api.Migrations
b.Property<int?>("ProductId") b.Property<int?>("ProductId")
.HasColumnType("integer"); .HasColumnType("integer");
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") b.Property<int?>("RecipeId")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -543,6 +557,9 @@ namespace YesChef.Api.Migrations
b.Property<int>("SortOrder") b.Property<int>("SortOrder")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<int?>("UnitOfMeasureId")
.HasColumnType("integer");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("CheckedByUserId"); b.HasIndex("CheckedByUserId");
@@ -551,6 +568,8 @@ namespace YesChef.Api.Migrations
b.HasIndex("FamilyProductId"); b.HasIndex("FamilyProductId");
b.HasIndex("FamilyUnitOfMeasureId");
b.HasIndex("ProductId"); b.HasIndex("ProductId");
b.HasIndex("RecipeId"); b.HasIndex("RecipeId");
@@ -561,6 +580,8 @@ namespace YesChef.Api.Migrations
b.HasIndex("ShoppingListId"); b.HasIndex("ShoppingListId");
b.HasIndex("UnitOfMeasureId");
b.ToTable("ShoppingListItems"); b.ToTable("ShoppingListItems");
}); });
@@ -965,6 +986,11 @@ namespace YesChef.Api.Migrations
.HasForeignKey("FamilyProductId") .HasForeignKey("FamilyProductId")
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
b.HasOne("YesChef.Api.Entities.FamilyUnitOfMeasure", "FamilyUnitOfMeasure")
.WithMany()
.HasForeignKey("FamilyUnitOfMeasureId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("YesChef.Api.Entities.Product", "Product") b.HasOne("YesChef.Api.Entities.Product", "Product")
.WithMany() .WithMany()
.HasForeignKey("ProductId") .HasForeignKey("ProductId")
@@ -991,12 +1017,19 @@ namespace YesChef.Api.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("YesChef.Api.Entities.UnitOfMeasure", "UnitOfMeasure")
.WithMany()
.HasForeignKey("UnitOfMeasureId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("CheckedByUser"); b.Navigation("CheckedByUser");
b.Navigation("Family"); b.Navigation("Family");
b.Navigation("FamilyProduct"); b.Navigation("FamilyProduct");
b.Navigation("FamilyUnitOfMeasure");
b.Navigation("Product"); b.Navigation("Product");
b.Navigation("Recipe"); b.Navigation("Recipe");
@@ -1006,6 +1039,8 @@ namespace YesChef.Api.Migrations
b.Navigation("Section"); b.Navigation("Section");
b.Navigation("ShoppingList"); b.Navigation("ShoppingList");
b.Navigation("UnitOfMeasure");
}); });
modelBuilder.Entity("YesChef.Api.Entities.Store", b => modelBuilder.Entity("YesChef.Api.Entities.Store", b =>
+94 -28
View File
@@ -6,6 +6,9 @@
import { startConnection, stopConnection } from '$lib/signalr'; import { startConnection, stopConnection } from '$lib/signalr';
import { toast } from '$lib/toast.svelte'; import { toast } from '$lib/toast.svelte';
import ProductTypeahead, { type ProductSuggestion } from '$lib/ProductTypeahead.svelte'; import ProductTypeahead, { type ProductSuggestion } from '$lib/ProductTypeahead.svelte';
import QuantityInput, { type QuantityValue } from '$lib/QuantityInput.svelte';
import { units } from '$lib/units.svelte';
import { formatQuantity } from '$lib/formatQuantity';
import type { HubConnection } from '@microsoft/signalr'; import type { HubConnection } from '@microsoft/signalr';
interface ListItem { interface ListItem {
@@ -16,6 +19,11 @@
sortOrder: number; sortOrder: number;
sectionId: number | null; sectionId: number | null;
recipeTitle: string | null; recipeTitle: string | null;
quantity: number | null;
unitOfMeasureId: number | null;
familyUnitOfMeasureId: number | null;
isApproximate: boolean;
quantityNote: string | null;
} }
interface Section { interface Section {
@@ -42,6 +50,9 @@
let newItemSectionId = $state<number | null>(null); let newItemSectionId = $state<number | null>(null);
let newItemProductId = $state<number | null>(null); let newItemProductId = $state<number | null>(null);
let newItemFamilyProductId = $state<number | null>(null); let newItemFamilyProductId = $state<number | null>(null);
let newItemQuantity = $state<QuantityValue>({ quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null });
let newItemIsApproximate = $state(false);
let newItemQuantityNote = $state('');
let loading = $state(true); let loading = $state(true);
let connection: HubConnection | null = null; let connection: HubConnection | null = null;
@@ -83,12 +94,18 @@
list = data; list = data;
items = data.items; items = data.items;
sections = data.sections; sections = data.sections;
// Touch the unit catalog so abbreviations are available for display.
void units.all;
loading = false; loading = false;
connection = await startConnection(); connection = await startConnection();
await connection.invoke('JoinList', listId); await connection.invoke('JoinList', listId);
connection.on('ItemAdded', (data: { id: number; name: string; sortOrder: number; sectionId: number | null; recipeTitle?: string }) => { connection.on('ItemAdded', (data: {
id: number; name: string; sortOrder: number; sectionId: number | null; recipeTitle?: string;
quantity: number | null; unitOfMeasureId: number | null; familyUnitOfMeasureId: number | null;
isApproximate: boolean; quantityNote: string | null;
}) => {
if (!items.find((i) => i.id === data.id)) { if (!items.find((i) => i.id === data.id)) {
items = [ items = [
...items, ...items,
@@ -99,7 +116,12 @@
checkedByUserName: null, checkedByUserName: null,
sortOrder: data.sortOrder, sortOrder: data.sortOrder,
sectionId: data.sectionId ?? null, sectionId: data.sectionId ?? null,
recipeTitle: data.recipeTitle ?? null recipeTitle: data.recipeTitle ?? null,
quantity: data.quantity,
unitOfMeasureId: data.unitOfMeasureId,
familyUnitOfMeasureId: data.familyUnitOfMeasureId,
isApproximate: data.isApproximate,
quantityNote: data.quantityNote
} }
]; ];
} }
@@ -154,12 +176,29 @@
sortOrder: maxSort + 1, sortOrder: maxSort + 1,
sectionId: newItemSectionId, sectionId: newItemSectionId,
productId: newItemProductId, productId: newItemProductId,
familyProductId: newItemFamilyProductId familyProductId: newItemFamilyProductId,
quantity: newItemIsApproximate ? null : newItemQuantity.quantity,
unitOfMeasureId: newItemIsApproximate ? null : newItemQuantity.unitOfMeasureId,
familyUnitOfMeasureId: newItemIsApproximate ? null : newItemQuantity.familyUnitOfMeasureId,
isApproximate: newItemIsApproximate,
quantityNote: newItemIsApproximate ? (newItemQuantityNote || null) : null
}) })
}); });
newItemName = ''; newItemName = '';
newItemProductId = null; newItemProductId = null;
newItemFamilyProductId = null; newItemFamilyProductId = null;
newItemQuantity = { quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null };
newItemIsApproximate = false;
newItemQuantityNote = '';
}
function toggleApproximateNewItem() {
newItemIsApproximate = !newItemIsApproximate;
if (newItemIsApproximate) {
newItemQuantity = { quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null };
} else {
newItemQuantityNote = '';
}
} }
function onItemProductChange(product: ProductSuggestion | null) { function onItemProductChange(product: ProductSuggestion | null) {
@@ -229,34 +268,53 @@
</button> </button>
</div> </div>
<form onsubmit={e => { e.preventDefault(); addItem(); }} class="mb-4 flex flex-wrap gap-2"> <form onsubmit={e => { e.preventDefault(); addItem(); }} class="mb-4 space-y-1">
<div class="min-w-0 flex-1"> <div class="flex flex-wrap gap-2">
<ProductTypeahead {#if newItemIsApproximate}
bind:value={newItemName} <input
placeholder="Add an item..." type="text"
ariaLabel="Item name" bind:value={newItemQuantityNote}
inputClass="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-base focus:border-primary focus:outline-none" placeholder="e.g. to taste"
onsubmit={addItem} class="w-36 rounded-lg border border-gray-300 px-2 py-2.5 text-sm focus:border-primary focus:outline-none"
onProductChange={onItemProductChange} />
/> {:else}
</div> <QuantityInput bind:value={newItemQuantity} />
{#if sections.length > 0} {/if}
<select <div class="min-w-0 flex-1">
bind:value={newItemSectionId} <ProductTypeahead
class="rounded-lg border border-gray-300 bg-white px-2 py-2.5 text-sm focus:border-primary focus:outline-none" bind:value={newItemName}
aria-label="Section" placeholder="Add an item..."
ariaLabel="Item name"
inputClass="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-base focus:border-primary focus:outline-none"
onsubmit={addItem}
onProductChange={onItemProductChange}
/>
</div>
{#if sections.length > 0}
<select
bind:value={newItemSectionId}
class="rounded-lg border border-gray-300 bg-white px-2 py-2.5 text-sm focus:border-primary focus:outline-none"
aria-label="Section"
>
<option value={null}>Uncategorized</option>
{#each sections as section (section.id)}
<option value={section.id}>{section.name}</option>
{/each}
</select>
{/if}
<button
type="submit"
class="rounded-lg bg-primary px-4 py-2.5 font-semibold text-white"
> >
<option value={null}>Uncategorized</option> Add
{#each sections as section (section.id)} </button>
<option value={section.id}>{section.name}</option> </div>
{/each}
</select>
{/if}
<button <button
type="submit" type="button"
class="rounded-lg bg-primary px-4 py-2.5 font-semibold text-white" onclick={toggleApproximateNewItem}
class="text-xs text-gray-500 hover:text-primary"
> >
Add {newItemIsApproximate ? '↩ use measured amount' : '~ approximate (e.g. to taste)'}
</button> </button>
</form> </form>
@@ -269,6 +327,7 @@
</h3> </h3>
<ul class="space-y-1"> <ul class="space-y-1">
{#each group.items as item (item.id)} {#each group.items as item (item.id)}
{@const qty = formatQuantity(item, units.all)}
<li class="flex items-center gap-3 rounded-lg bg-white px-3 py-3 shadow-sm"> <li class="flex items-center gap-3 rounded-lg bg-white px-3 py-3 shadow-sm">
<button <button
onclick={() => toggleItem(item.id)} onclick={() => toggleItem(item.id)}
@@ -276,6 +335,9 @@
aria-label="Check {item.name}" aria-label="Check {item.name}"
></button> ></button>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
{#if qty}
<span class="text-base font-medium text-primary">{qty}</span>
{/if}
<span class="text-base">{item.name}</span> <span class="text-base">{item.name}</span>
{#if item.recipeTitle} {#if item.recipeTitle}
<span class="ml-1 text-xs text-gray-400">from {item.recipeTitle}</span> <span class="ml-1 text-xs text-gray-400">from {item.recipeTitle}</span>
@@ -324,6 +386,7 @@
</h4> </h4>
<ul class="space-y-1"> <ul class="space-y-1">
{#each group.items as item (item.id)} {#each group.items as item (item.id)}
{@const qty = formatQuantity(item, units.all)}
<li class="flex items-center gap-3 rounded-lg bg-white/60 px-3 py-3 shadow-sm"> <li class="flex items-center gap-3 rounded-lg bg-white/60 px-3 py-3 shadow-sm">
<button <button
onclick={() => toggleItem(item.id)} onclick={() => toggleItem(item.id)}
@@ -333,6 +396,9 @@
</button> </button>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
{#if qty}
<span class="text-base text-gray-400 line-through">{qty}</span>
{/if}
<span class="text-base text-gray-400 line-through">{item.name}</span> <span class="text-base text-gray-400 line-through">{item.name}</span>
{#if item.checkedByUserName} {#if item.checkedByUserName}
<span class="ml-1 text-xs text-gray-300">{item.checkedByUserName}</span> <span class="ml-1 text-xs text-gray-300">{item.checkedByUserName}</span>