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:
@@ -6,6 +6,9 @@
|
||||
import { startConnection, stopConnection } from '$lib/signalr';
|
||||
import { toast } from '$lib/toast.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';
|
||||
|
||||
interface ListItem {
|
||||
@@ -16,6 +19,11 @@
|
||||
sortOrder: number;
|
||||
sectionId: number | null;
|
||||
recipeTitle: string | null;
|
||||
quantity: number | null;
|
||||
unitOfMeasureId: number | null;
|
||||
familyUnitOfMeasureId: number | null;
|
||||
isApproximate: boolean;
|
||||
quantityNote: string | null;
|
||||
}
|
||||
|
||||
interface Section {
|
||||
@@ -42,6 +50,9 @@
|
||||
let newItemSectionId = $state<number | null>(null);
|
||||
let newItemProductId = $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 connection: HubConnection | null = null;
|
||||
|
||||
@@ -83,12 +94,18 @@
|
||||
list = data;
|
||||
items = data.items;
|
||||
sections = data.sections;
|
||||
// Touch the unit catalog so abbreviations are available for display.
|
||||
void units.all;
|
||||
loading = false;
|
||||
|
||||
connection = await startConnection();
|
||||
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)) {
|
||||
items = [
|
||||
...items,
|
||||
@@ -99,7 +116,12 @@
|
||||
checkedByUserName: null,
|
||||
sortOrder: data.sortOrder,
|
||||
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,
|
||||
sectionId: newItemSectionId,
|
||||
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 = '';
|
||||
newItemProductId = 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) {
|
||||
@@ -229,34 +268,53 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onsubmit={e => { e.preventDefault(); addItem(); }} class="mb-4 flex flex-wrap gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<ProductTypeahead
|
||||
bind:value={newItemName}
|
||||
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"
|
||||
<form onsubmit={e => { e.preventDefault(); addItem(); }} class="mb-4 space-y-1">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#if newItemIsApproximate}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newItemQuantityNote}
|
||||
placeholder="e.g. to taste"
|
||||
class="w-36 rounded-lg border border-gray-300 px-2 py-2.5 text-sm focus:border-primary focus:outline-none"
|
||||
/>
|
||||
{:else}
|
||||
<QuantityInput bind:value={newItemQuantity} />
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<ProductTypeahead
|
||||
bind:value={newItemName}
|
||||
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>
|
||||
{#each sections as section (section.id)}
|
||||
<option value={section.id}>{section.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-primary px-4 py-2.5 font-semibold text-white"
|
||||
type="button"
|
||||
onclick={toggleApproximateNewItem}
|
||||
class="text-xs text-gray-500 hover:text-primary"
|
||||
>
|
||||
Add
|
||||
{newItemIsApproximate ? '↩ use measured amount' : '~ approximate (e.g. to taste)'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -269,6 +327,7 @@
|
||||
</h3>
|
||||
<ul class="space-y-1">
|
||||
{#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">
|
||||
<button
|
||||
onclick={() => toggleItem(item.id)}
|
||||
@@ -276,6 +335,9 @@
|
||||
aria-label="Check {item.name}"
|
||||
></button>
|
||||
<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>
|
||||
{#if item.recipeTitle}
|
||||
<span class="ml-1 text-xs text-gray-400">from {item.recipeTitle}</span>
|
||||
@@ -324,6 +386,7 @@
|
||||
</h4>
|
||||
<ul class="space-y-1">
|
||||
{#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">
|
||||
<button
|
||||
onclick={() => toggleItem(item.id)}
|
||||
@@ -333,6 +396,9 @@
|
||||
✓
|
||||
</button>
|
||||
<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>
|
||||
{#if item.checkedByUserName}
|
||||
<span class="ml-1 text-xs text-gray-300">{item.checkedByUserName}</span>
|
||||
|
||||
Reference in New Issue
Block a user