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:
@@ -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