Add product catalog with per-store section memory

Introduces a global Products catalog plus per-family overrides and
private FamilyProducts, exposed via /api/products with a merged
search. Shopping list items and recipe ingredients gain optional
ProductId/FamilyProductId links, and a new ProductStoreSection
table remembers which section a product was last placed in at a
given store so future adds auto-assign the right section.

Frontend gets a reusable ProductTypeahead component, wired into
list-item add and recipe ingredient entry with free-form fallback.

A startup CatalogSeeder loads ~115 curated staples from an embedded
JSON resource via INSERT ... ON CONFLICT DO NOTHING; skipped under
the Testing environment so integration tests keep a clean slate.
This commit is contained in:
Josh Rogers
2026-05-09 21:29:51 -05:00
parent 5c6abc1e43
commit 6c8f0167e5
27 changed files with 4621 additions and 36 deletions
@@ -0,0 +1,174 @@
<script lang="ts" module>
export interface ProductSuggestion {
id: number;
kind: 'Global' | 'Family';
name: string;
brand: string | null;
notes: string | null;
isOverridden: boolean;
}
</script>
<script lang="ts">
import { api } from '$lib/api';
interface Props {
value: string;
placeholder?: string;
ariaLabel?: string;
inputClass?: string;
onsubmit?: () => void;
// Fires when the user picks a suggestion (with the chosen product),
// or when they edit the input after a selection (with null) — the
// caller uses this to track / clear an associated product link.
onProductChange?: (product: ProductSuggestion | null) => void;
}
let {
value = $bindable(''),
placeholder = '',
ariaLabel = 'Product',
inputClass = '',
onsubmit,
onProductChange,
}: Props = $props();
// Tracks the most recent picked product so we can detect when the user
// edits the input afterward (which invalidates the link).
let lastSelectedName: string | null = null;
let suggestions = $state<ProductSuggestion[]>([]);
let showDropdown = $state(false);
let activeIndex = $state(-1);
const listboxId = `product-typeahead-${crypto.randomUUID().slice(0, 8)}`;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
// Increment per request; only the most recent response wins to avoid
// out-of-order responses overwriting newer suggestions.
let requestSeq = 0;
async function fetchSuggestions(query: string) {
const seq = ++requestSeq;
try {
const results = await api<ProductSuggestion[]>(
`/api/products?q=${encodeURIComponent(query)}`,
);
if (seq !== requestSeq) return;
suggestions = results;
showDropdown = results.length > 0;
activeIndex = -1;
} catch {
if (seq !== requestSeq) return;
suggestions = [];
showDropdown = false;
}
}
function onInput() {
if (debounceTimer) clearTimeout(debounceTimer);
// Any keystroke that diverges from the last picked product invalidates
// the link — the caller needs to know so it stops sending the stale id.
if (lastSelectedName !== null && value !== lastSelectedName) {
lastSelectedName = null;
onProductChange?.(null);
}
const trimmed = value.trim();
if (trimmed.length === 0) {
suggestions = [];
showDropdown = false;
activeIndex = -1;
return;
}
debounceTimer = setTimeout(() => fetchSuggestions(trimmed), 200);
}
function selectSuggestion(s: ProductSuggestion) {
value = s.name;
lastSelectedName = s.name;
suggestions = [];
showDropdown = false;
activeIndex = -1;
onProductChange?.(s);
}
function onKeydown(e: KeyboardEvent) {
if (!showDropdown || suggestions.length === 0) {
if (e.key === 'Enter' && onsubmit) {
e.preventDefault();
onsubmit();
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIndex = (activeIndex + 1) % suggestions.length;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex = activeIndex <= 0 ? suggestions.length - 1 : activeIndex - 1;
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0) selectSuggestion(suggestions[activeIndex]);
else if (onsubmit) onsubmit();
} else if (e.key === 'Escape') {
showDropdown = false;
activeIndex = -1;
}
}
function onBlur() {
// Delay so that a mousedown-then-mouseup click on a suggestion is
// registered before the dropdown closes.
setTimeout(() => {
showDropdown = false;
activeIndex = -1;
}, 120);
}
</script>
<div class="relative">
<input
type="text"
bind:value
oninput={onInput}
onkeydown={onKeydown}
onblur={onBlur}
onfocus={() => {
if (suggestions.length > 0) showDropdown = true;
}}
{placeholder}
aria-label={ariaLabel}
aria-autocomplete="list"
aria-expanded={showDropdown}
aria-controls={listboxId}
role="combobox"
class={inputClass}
autocomplete="off"
/>
{#if showDropdown}
<ul
id={listboxId}
class="absolute left-0 right-0 z-10 mt-1 max-h-64 overflow-auto rounded-lg border border-gray-200 bg-white shadow-lg"
role="listbox"
>
{#each suggestions as suggestion, i (suggestion.kind + suggestion.id)}
<li
role="option"
aria-selected={i === activeIndex}
class="cursor-pointer px-3 py-2 text-sm {i === activeIndex
? 'bg-primary/10'
: 'hover:bg-gray-50'}"
onmousedown={(e) => {
// mousedown rather than click so we beat the blur handler.
e.preventDefault();
selectSuggestion(suggestion);
}}
>
<span>{suggestion.name}</span>
{#if suggestion.brand}
<span class="ml-2 text-xs text-gray-400">{suggestion.brand}</span>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
@@ -5,6 +5,7 @@
import { api } from '$lib/api';
import { startConnection, stopConnection } from '$lib/signalr';
import { toast } from '$lib/toast.svelte';
import ProductTypeahead, { type ProductSuggestion } from '$lib/ProductTypeahead.svelte';
import type { HubConnection } from '@microsoft/signalr';
interface ListItem {
@@ -39,6 +40,8 @@
let sections = $state<Section[]>([]);
let newItemName = $state('');
let newItemSectionId = $state<number | null>(null);
let newItemProductId = $state<number | null>(null);
let newItemFamilyProductId = $state<number | null>(null);
let loading = $state(true);
let connection: HubConnection | null = null;
@@ -149,10 +152,27 @@
body: JSON.stringify({
name: newItemName,
sortOrder: maxSort + 1,
sectionId: newItemSectionId
sectionId: newItemSectionId,
productId: newItemProductId,
familyProductId: newItemFamilyProductId
})
});
newItemName = '';
newItemProductId = null;
newItemFamilyProductId = null;
}
function onItemProductChange(product: ProductSuggestion | null) {
if (product === null) {
newItemProductId = null;
newItemFamilyProductId = null;
} else if (product.kind === 'Global') {
newItemProductId = product.id;
newItemFamilyProductId = null;
} else {
newItemProductId = null;
newItemFamilyProductId = product.id;
}
}
async function toggleItem(itemId: number) {
@@ -210,12 +230,16 @@
</div>
<form onsubmit={e => { e.preventDefault(); addItem(); }} class="mb-4 flex flex-wrap gap-2">
<input
type="text"
bind:value={newItemName}
placeholder="Add an item..."
class="min-w-0 flex-1 rounded-lg border border-gray-300 px-3 py-2.5 text-base focus:border-primary focus:outline-none"
/>
<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}
@@ -1,22 +1,48 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import ProductTypeahead, { type ProductSuggestion } from '$lib/ProductTypeahead.svelte';
interface IngredientForm {
name: string;
quantity: string;
productId: number | null;
familyProductId: number | null;
}
let title = $state('');
let description = $state('');
let instructions = $state('');
let servings = $state<number | undefined>();
let ingredients = $state<{ name: string; quantity: string }[]>([{ name: '', quantity: '' }]);
let ingredients = $state<IngredientForm[]>([emptyIngredient()]);
let saving = $state(false);
function emptyIngredient(): IngredientForm {
return { name: '', quantity: '', productId: null, familyProductId: null };
}
function addIngredient() {
ingredients = [...ingredients, { name: '', quantity: '' }];
ingredients = [...ingredients, emptyIngredient()];
}
function removeIngredient(idx: number) {
ingredients = ingredients.filter((_, i) => i !== idx);
}
function onIngredientProductChange(idx: number, product: ProductSuggestion | null) {
const next = ingredients[idx];
if (product === null) {
next.productId = null;
next.familyProductId = null;
} else if (product.kind === 'Global') {
next.productId = product.id;
next.familyProductId = null;
} else {
next.productId = null;
next.familyProductId = product.id;
}
}
async function save() {
if (!title.trim()) return;
saving = true;
@@ -31,7 +57,13 @@
sourceUrl: null,
ingredients: ingredients
.filter((i) => i.name.trim())
.map((i, idx) => ({ name: i.name, quantity: i.quantity || null, sortOrder: idx }))
.map((i, idx) => ({
name: i.name,
quantity: i.quantity || null,
sortOrder: idx,
productId: i.productId,
familyProductId: i.familyProductId
}))
})
});
goto(`/recipes/${res.id}`);
@@ -84,12 +116,15 @@
placeholder="Qty"
class="w-20 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none"
/>
<input
type="text"
bind:value={ingredient.name}
placeholder="Ingredient name"
class="flex-1 rounded-lg border border-gray-300 px-3 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>
{#if ingredients.length > 1}
<button
type="button"