Add structured quantities + units to recipe ingredients

Phase 2 of structured quantities + UoM. Replaces the free-form Quantity
string on RecipeIngredient with a structured (Quantity decimal, UnitOfMeasureId
or FamilyUnitOfMeasureId) pair, plus an IsApproximate + QuantityNote
escape hatch for "to taste" style entries. The unit FK pair mirrors the
existing Product / FamilyProduct pattern, with the same at-most-one and
tenant-scoping validation. Existing string Quantity values are dropped
per the agreed wipe-to-null migration plan.

Frontend ships a QuantityInput component (numeric field + unit dropdown
fed by a runes-cached effective catalog from /api/units) and a shared
formatter for read-only display. Recipe -> shopping list copy folds the
structured quantity into the item Name for now; Phase 3 will move the
fields onto ShoppingListItem directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Josh Rogers
2026-05-12 21:36:25 -05:00
parent 559d80c104
commit c7f9c31952
17 changed files with 1805 additions and 89 deletions
+78
View File
@@ -0,0 +1,78 @@
<script lang="ts" module>
export interface QuantityValue {
quantity: number | null;
unitOfMeasureId: number | null;
familyUnitOfMeasureId: number | null;
}
</script>
<script lang="ts">
import { units } from '$lib/units.svelte';
interface Props {
value: QuantityValue;
ariaLabel?: string;
}
let { value = $bindable(), ariaLabel = 'Quantity' }: Props = $props();
// The unit dropdown carries a composite id "kind:id" so we can route to
// the right FK column on the server. "" = no unit selected.
const composite = $derived.by(() => {
if (value.unitOfMeasureId !== null) return `Global:${value.unitOfMeasureId}`;
if (value.familyUnitOfMeasureId !== null) return `Family:${value.familyUnitOfMeasureId}`;
return '';
});
function onUnitChange(e: Event) {
const raw = (e.currentTarget as HTMLSelectElement).value;
if (raw === '') {
value.unitOfMeasureId = null;
value.familyUnitOfMeasureId = null;
return;
}
const [kind, idStr] = raw.split(':');
const id = Number(idStr);
if (kind === 'Global') {
value.unitOfMeasureId = id;
value.familyUnitOfMeasureId = null;
} else {
value.unitOfMeasureId = null;
value.familyUnitOfMeasureId = id;
}
}
function onQuantityInput(e: Event) {
const raw = (e.currentTarget as HTMLInputElement).value;
value.quantity = raw === '' ? null : Number(raw);
}
const sorted = $derived(
[...units.all].sort((a, b) => a.sortOrder - b.sortOrder || a.singularName.localeCompare(b.singularName))
);
</script>
<div class="flex gap-1">
<input
type="number"
step="0.01"
min="0"
inputmode="decimal"
value={value.quantity ?? ''}
oninput={onQuantityInput}
placeholder="Qty"
aria-label={ariaLabel}
class="w-20 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none"
/>
<select
value={composite}
onchange={onUnitChange}
aria-label="Unit"
class="w-24 rounded-lg border border-gray-300 px-1 py-2 text-sm focus:border-primary focus:outline-none"
>
<option value="">unit</option>
{#each sorted as unit}
<option value="{unit.kind}:{unit.id}">{unit.abbreviation}</option>
{/each}
</select>
</div>
+47
View File
@@ -0,0 +1,47 @@
import type { Unit } from '$lib/units.svelte';
export interface QuantityLike {
quantity: number | null;
unitOfMeasureId: number | null;
familyUnitOfMeasureId: number | null;
isApproximate: boolean;
quantityNote: string | null;
}
/**
* Format a structured quantity for display alongside an ingredient name.
* Returns null when there's nothing to render (no quantity and not approximate).
*
* Examples (when paired with a unit lookup):
* { quantity: 2, unit cup } -> "2 cup"
* { quantity: 1.5, unit lb } -> "1.5 lb"
* { quantity: 3, unit null } -> "3"
* { isApproximate, note "..." } -> "to taste"
*/
export function formatQuantity(q: QuantityLike, units: Unit[]): string | null {
if (q.isApproximate) {
return q.quantityNote?.trim() || null;
}
if (q.quantity === null) return null;
const unit = lookupUnit(q, units);
const qty = trimTrailingZeros(q.quantity);
return unit ? `${qty} ${unit.abbreviation}` : qty;
}
function lookupUnit(q: QuantityLike, units: Unit[]): Unit | null {
if (q.unitOfMeasureId !== null) {
return units.find((u) => u.id === q.unitOfMeasureId && u.kind === 'Global') ?? null;
}
if (q.familyUnitOfMeasureId !== null) {
return units.find((u) => u.id === q.familyUnitOfMeasureId && u.kind === 'Family') ?? null;
}
return null;
}
function trimTrailingZeros(n: number): string {
// "2.0000" → "2", "1.5000" → "1.5". Keeps fractional precision when present.
return n
.toFixed(4)
.replace(/\.?0+$/, '');
}
+54
View File
@@ -0,0 +1,54 @@
import { api } from '$lib/api';
export type UnitKind = 'Global' | 'Family';
export type UnitCategory = 'Count' | 'Weight' | 'Volume' | 'Packaging';
export interface Unit {
id: number;
kind: UnitKind;
code: string | null;
singularName: string;
pluralName: string;
abbreviation: string;
category: UnitCategory;
isBase: boolean;
sortOrder: number;
}
// In-memory cache of the effective unit catalog for the current session.
// Single fetch per page-load; refresh on demand via reload().
let cache = $state<Unit[] | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
export const units = {
get all() {
if (cache === null && !loading) {
void load();
}
return cache ?? [];
},
get loading() {
return loading;
},
get error() {
return error;
},
reload: load,
byId(id: number, kind: UnitKind): Unit | null {
return cache?.find((u) => u.id === id && u.kind === kind) ?? null;
},
};
async function load() {
loading = true;
error = null;
try {
cache = await api<Unit[]>('/api/units');
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load units';
cache = [];
} finally {
loading = false;
}
}
@@ -3,12 +3,18 @@
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { units } from '$lib/units.svelte';
import { formatQuantity } from '$lib/formatQuantity';
interface Ingredient {
id: number;
name: string;
quantity: string | null;
sortOrder: number;
quantity: number | null;
unitOfMeasureId: number | null;
familyUnitOfMeasureId: number | null;
isApproximate: boolean;
quantityNote: string | null;
}
interface Recipe {
@@ -41,6 +47,8 @@
api<Recipe>(`/api/recipes/${recipeId}`),
api<ListSummary[]>('/api/lists')
]);
// Touch the units store to kick off the catalog fetch.
void units.all;
loading = false;
});
@@ -125,9 +133,10 @@
<h3 class="mb-2 text-lg font-semibold">Ingredients</h3>
<ul class="space-y-1.5">
{#each recipe.ingredients as ingredient}
{@const display = formatQuantity(ingredient, units.all)}
<li class="flex gap-2 rounded-lg bg-white px-3 py-2 shadow-sm">
{#if ingredient.quantity}
<span class="font-medium text-primary">{ingredient.quantity}</span>
{#if display}
<span class="font-medium text-primary">{display}</span>
{/if}
<span>{ingredient.name}</span>
</li>
@@ -2,10 +2,13 @@
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import ProductTypeahead, { type ProductSuggestion } from '$lib/ProductTypeahead.svelte';
import QuantityInput, { type QuantityValue } from '$lib/QuantityInput.svelte';
interface IngredientForm {
name: string;
quantity: string;
quantity: QuantityValue;
isApproximate: boolean;
quantityNote: string;
productId: number | null;
familyProductId: number | null;
}
@@ -18,7 +21,14 @@
let saving = $state(false);
function emptyIngredient(): IngredientForm {
return { name: '', quantity: '', productId: null, familyProductId: null };
return {
name: '',
quantity: { quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null },
isApproximate: false,
quantityNote: '',
productId: null,
familyProductId: null,
};
}
function addIngredient() {
@@ -29,6 +39,16 @@
ingredients = ingredients.filter((_, i) => i !== idx);
}
function toggleApproximate(idx: number) {
const ing = ingredients[idx];
ing.isApproximate = !ing.isApproximate;
if (ing.isApproximate) {
ing.quantity = { quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null };
} else {
ing.quantityNote = '';
}
}
function onIngredientProductChange(idx: number, product: ProductSuggestion | null) {
const next = ingredients[idx];
if (product === null) {
@@ -59,8 +79,12 @@
.filter((i) => i.name.trim())
.map((i, idx) => ({
name: i.name,
quantity: i.quantity || null,
sortOrder: idx,
quantity: i.isApproximate ? null : i.quantity.quantity,
unitOfMeasureId: i.isApproximate ? null : i.quantity.unitOfMeasureId,
familyUnitOfMeasureId: i.isApproximate ? null : i.quantity.familyUnitOfMeasureId,
isApproximate: i.isApproximate,
quantityNote: i.isApproximate ? (i.quantityNote || null) : null,
productId: i.productId,
familyProductId: i.familyProductId
}))
@@ -109,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