16 KiB
Phase 22: Add-from-Catalog & Thread Integration - Research
Researched: 2026-04-06 Domain: React modals, Zustand state, TanStack Query mutations, catalog-to-collection/thread flows Confidence: HIGH
Summary
This phase wires the stub "Add" buttons in the catalog search overlay and global item detail page to actual add-to-collection and add-to-thread flows. The backend infrastructure is fully ready: createItemSchema and createCandidateSchema both accept globalItemId, the item service creates reference items with global item data merge, and thread resolution already handles catalog-linked candidates. The work is entirely frontend: two new modal components, UIStore state slices, toast notifications, and handler wiring.
The existing codebase provides strong patterns to follow. CreateThreadModal demonstrates the modal pattern (backdrop + form + Zustand open/close). useCreateItem() and useCreateCandidate(threadId) are the exact mutation hooks needed. The CatalogSearchOverlay already distinguishes catalogSearchMode === "collection" vs "thread", and handleAddStub is the single integration point to replace.
Primary recommendation: Build two standalone modal components (AddToCollectionModal, AddToThreadModal), add a lightweight toast system (sonner), extend UIStore with modal + session state, and wire the overlay/detail page handlers. No backend changes needed.
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
- D-01 through D-05: Add-to-Collection modal with category dropdown, optional notes, optional purchase price, success toast, standalone lightweight component
- D-06 through D-09: Add-to-Thread modal with thread picker for active threads, success toast, empty state with "Create Thread First"
- D-10 through D-13: Start Thread flow creates thread + first candidate in one action, subsequent adds default to just-created thread
- D-14 through D-15: Global item detail page gets both "Add to Collection" and "Add to Thread" buttons
- D-16 through D-19: Replace handleAddStub, mode-based handler dispatch, session-level selectedThreadId
- D-20 through D-22: UIStore modal states for addToCollectionModal, addToThreadModal, catalogSessionThreadId
Claude's Discretion
- Modal animation style and exact layout proportions
- Whether category dropdown auto-selects based on global item category name match or leaves unselected
- Toast notification library/pattern
- Whether thread picker shows thread category alongside name
- Exact field ordering in add-to-collection modal
- Whether purchase price field uses currency formatting input or plain number
Deferred Ideas (OUT OF SCOPE)
- "Add Manually" link in catalog search empty state (Phase 23)
- Manual entry fallback for items not in catalog (Phase 23)
- Bulk add multiple items at once
- "Quick add" without any confirmation (one-tap add with defaults)
- Quantity selection during add (default to 1) </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| CATFLOW-03 | User can add a catalog item to collection as a reference item with personal fields | AddToCollectionModal calls useCreateItem() with globalItemId + categoryId + optional notes/purchasePriceCents. Service layer already merges global item data. |
| CATFLOW-05 | Thread candidates can be added from catalog with global item link | AddToThreadModal calls useCreateCandidate(threadId) with globalItemId and global item data. Schema already supports globalItemId on candidates. |
| CATFLOW-06 | Thread resolution with catalog-linked candidate creates reference item with auto-link | Already implemented in resolveThread() (thread.service.ts:312+). Branches on candidate.globalItemId to create reference item. No new work needed -- verify with E2E test. |
| </phase_requirements> |
Standard Stack
Core (already in project)
| Library | Purpose | Why Standard |
|---|---|---|
| React 19 | UI components | Project framework |
| Zustand | UIStore modal/session state | Established pattern for all UI state |
| TanStack React Query | Mutations and cache invalidation | Established pattern for all data ops |
| Framer Motion | Modal animations | Already used for overlay animations |
Supporting (new addition)
| Library | Purpose | Why |
|---|---|---|
| sonner | Toast notifications | Lightweight (< 5KB), headless-friendly, works with Tailwind. No toast lib exists in project yet. |
Alternatives Considered
| Instead of | Could Use | Tradeoff |
|---|---|---|
| sonner | Custom inline toast | Sonner handles stacking, auto-dismiss, accessibility out of the box |
| sonner | react-hot-toast | Both good; sonner has better default styling and smaller bundle |
| Separate modals | Single CatalogAddModal with mode switch | Two separate modals is cleaner -- different form fields, different submit logic |
Installation:
bun add sonner
Discretion note: Sonner is recommended but any lightweight toast approach works. A custom 20-line toast component using useState + setTimeout is also viable if avoiding new dependencies is preferred.
Architecture Patterns
New Component Structure
src/client/components/
AddToCollectionModal.tsx # Category picker + notes + purchase price
AddToThreadModal.tsx # Thread picker + "New Thread..." option
Toast.tsx # Sonner <Toaster /> wrapper (or custom)
Pattern 1: Modal with Zustand State
What: Modal open/close controlled by UIStore, form state local to component When to use: All modals in this project Example:
// UIStore slice
addToCollectionModal: { open: boolean; globalItemId: number | null; globalItemName: string | null },
openAddToCollection: (globalItemId: number, globalItemName: string) =>
set({ addToCollectionModal: { open: true, globalItemId, globalItemName } }),
closeAddToCollection: () =>
set({ addToCollectionModal: { open: false, globalItemId: null, globalItemName: null } }),
Pattern 2: Session Thread Tracking
What: Remember selected thread within a catalog search session When to use: When user adds multiple items to same thread Example:
// UIStore
catalogSessionThreadId: number | null,
setCatalogSessionThreadId: (id: number | null) => set({ catalogSessionThreadId: id }),
// Reset when overlay closes:
closeCatalogSearch: () => set({
catalogSearchOpen: false,
catalogSearchMode: null,
catalogSessionThreadId: null, // auto-reset
}),
Pattern 3: Category Auto-Match
What: Pre-select user's category that matches global item's category name When to use: Add-to-collection modal Recommendation: Auto-select matching category by name comparison (case-insensitive). Falls back to first category if no match.
const matchedCategory = categories?.find(
(c) => c.name.toLowerCase() === globalItemCategory?.toLowerCase()
);
const defaultCategoryId = matchedCategory?.id ?? categories?.[0]?.id ?? null;
Pattern 4: Combined Thread Creation + Candidate Add
What: Create thread and add first candidate in sequential mutations When to use: "New Thread..." option in thread picker, or first add in "Start Thread" flow Example:
async function handleCreateThreadAndAddCandidate() {
const thread = await createThread.mutateAsync({ name: threadName, categoryId });
await createCandidate.mutateAsync({
name: `${globalItem.brand} ${globalItem.model}`,
globalItemId: globalItem.id,
categoryId: thread.categoryId,
weightGrams: globalItem.weightGrams,
priceCents: globalItem.priceCents,
});
setCatalogSessionThreadId(thread.id);
toast.success(`Created "${threadName}" with first candidate`);
}
Anti-Patterns to Avoid
- Don't pass full global item objects through UIStore: Store only
globalItemId+globalItemNamein UIStore. Fetch full data in the modal viauseGlobalItem(id)if needed, or pass additional fields as modal props. - Don't create a new
useCreateCandidatevariant: The existing hook takesthreadIdas parameter -- use it directly. For the combined flow, calluseCreateThread().mutateAsync()first to get the thread ID. - Don't modify backend routes or schemas: Everything needed is already in place.
createItemSchemahasglobalItemId,createCandidateSchemahasglobalItemId.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Toast notifications | Custom notification system | sonner (or simple inline toast) | Stacking, auto-dismiss, accessibility, animation |
| Modal backdrop/focus trap | Custom portal + focus management | Existing pattern from CreateThreadModal | Consistent with codebase, already works |
| Category matching logic | Complex fuzzy matcher | Simple case-insensitive string equality | Categories are user-created, exact match is reliable enough |
Common Pitfalls
Pitfall 1: useCreateCandidate Requires Known threadId
What goes wrong: The useCreateCandidate(threadId) hook takes threadId at call time (hook parameter), not in the mutation payload. For the "New Thread..." flow, the thread doesn't exist yet when the hook is initialized.
Why it happens: Hook is designed for use within a thread detail view where threadId is known.
How to avoid: Use apiPost directly for the combined create-thread-then-add-candidate flow, OR call useCreateCandidate with a ref/state that updates after thread creation and trigger mutation after. Simplest: use mutateAsync on create thread, then call apiPost for the candidate with the returned thread ID.
Warning signs: Hook called with 0 or null threadId.
Pitfall 2: Query Invalidation After Combined Operations
What goes wrong: Creating a thread + candidate requires invalidating both ["threads"] and ["threads", newThreadId] query keys. Missing one leaves stale data.
Why it happens: Two separate mutations, each with partial invalidation.
How to avoid: After combined create, invalidate ["threads"] broadly. The useCreateThread hook already does this. For the candidate, manually call queryClient.invalidateQueries({ queryKey: ["threads"] }).
Pitfall 3: Modal State Not Reset on Close
What goes wrong: Opening modal again shows previous form data.
Why it happens: Local state not cleared when UIStore open changes to false.
How to avoid: Use useEffect watching open state to reset form fields, same pattern as CreateThreadModal which resets on category load.
Pitfall 4: CatalogSearchOverlay Closes Before Modal Opens
What goes wrong: User clicks "Add" on a catalog card, overlay closes (via existing close behavior), modal has no context.
Why it happens: The overlay's handleAddStub might be confused with card click navigation.
How to avoid: The "Add" button already calls e.stopPropagation() to prevent card click. The modal should open ON TOP of the overlay (higher z-index), not replace it. Overlay stays open while modal is visible.
Pitfall 5: Global Item Category Is a String, User Categories Are Objects
What goes wrong: Trying to match globalItem.category (string like "Shelter") against categories[].id (number).
Why it happens: Global items store category as a plain string field, not a foreign key.
How to avoid: Match on categories[].name (string comparison), not on ID.
Code Examples
AddToCollectionModal Core Structure
// Follows CreateThreadModal pattern exactly
function AddToCollectionModal() {
const { open, globalItemId, globalItemName } = useUIStore((s) => s.addToCollectionModal);
const close = useUIStore((s) => s.closeAddToCollection);
const { data: categories } = useCategories();
const createItem = useCreateItem();
const [categoryId, setCategoryId] = useState<number | null>(null);
const [notes, setNotes] = useState("");
const [purchasePriceCents, setPurchasePriceCents] = useState<number | undefined>();
if (!open || !globalItemId) return null;
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!categoryId || !globalItemId) return;
createItem.mutate({
name: globalItemName ?? "Unknown Item", // Required by schema, service overwrites for reference items
categoryId,
globalItemId,
notes: notes || undefined,
purchasePriceCents: purchasePriceCents || undefined,
}, {
onSuccess: () => {
toast.success("Added to Collection");
close();
},
});
}
// ... render modal form
}
Handler Wiring in CatalogSearchOverlay
// Replace handleAddStub
function handleAdd(e: React.MouseEvent, item: { id: number; brand: string; model: string }) {
e.stopPropagation();
if (catalogSearchMode === "collection") {
openAddToCollection(item.id, `${item.brand} ${item.model}`);
} else if (catalogSearchMode === "thread") {
openAddToThread(item.id, `${item.brand} ${item.model}`);
}
}
Thread Picker with "New Thread..." Option
const { data: threads } = useThreads(); // defaults to active only
const activeThreads = threads?.filter((t) => t.status === "active") ?? [];
<select value={selectedThreadId ?? ""} onChange={handleThreadSelect}>
{activeThreads.length === 0 && (
<option value="" disabled>No active threads</option>
)}
{activeThreads.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({t.categoryName})
</option>
))}
<option value="new">+ New Thread...</option>
</select>
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
| Slide-out panels for item edit | Full page routes for detail | Phase 21 | Modals are now lightweight overlays, not form-heavy panels |
| Manual item creation only | Reference items via globalItemId | Phase 19 | createItem service auto-merges global data |
| No toast system | Need toast for add confirmations | Phase 22 (this phase) | First toast usage in codebase |
Validation Architecture
Test Framework
| Property | Value |
|---|---|
| Framework | Bun test + Playwright |
| Config file | playwright.config.ts (E2E), bunfig.toml (unit) |
| Quick run command | bun test tests/services/item.service.test.ts |
| Full suite command | bun test && bun run test:e2e |
Phase Requirements -> Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| CATFLOW-03 | Add catalog item to collection as reference item | E2E | bun run test:e2e -- --grep "add from catalog" |
No -- Wave 0 |
| CATFLOW-05 | Add catalog item as thread candidate | E2E | bun run test:e2e -- --grep "catalog candidate" |
No -- Wave 0 |
| CATFLOW-06 | Thread resolution preserves catalog link | unit | bun test tests/services/thread.service.test.ts |
Partial -- resolve tests exist but may not cover globalItemId branch |
Sampling Rate
- Per task commit:
bun test tests/services/item.service.test.ts tests/services/thread.service.test.ts - Per wave merge:
bun test && bun run test:e2e - Phase gate: Full suite green before verify
Wave 0 Gaps
- E2E test for add-from-catalog-to-collection flow
- E2E test for add-from-catalog-to-thread flow
- Verify existing thread resolution test covers globalItemId candidate branch
Sources
Primary (HIGH confidence)
- Direct codebase inspection of all referenced files
src/shared/schemas.ts-- confirmedglobalItemIdon bothcreateItemSchemaandcreateCandidateSchemasrc/server/services/item.service.ts-- confirmed reference item creation patternsrc/server/services/thread.service.ts:312+-- confirmed catalog-linked resolution branchsrc/client/stores/uiStore.ts-- confirmed existing modal state patternssrc/client/components/CreateThreadModal.tsx-- confirmed modal component patternsrc/client/hooks/useItems.ts,useCandidates.ts,useThreads.ts-- confirmed mutation hooks
Secondary (MEDIUM confidence)
- sonner library recommendation based on ecosystem knowledge (lightweight, Tailwind-compatible)
Metadata
Confidence breakdown:
- Standard stack: HIGH -- all core libraries already in project, only sonner is new (optional)
- Architecture: HIGH -- patterns directly derived from existing codebase components
- Pitfalls: HIGH -- identified from actual code inspection of hook signatures and data types
Research date: 2026-04-06 Valid until: 2026-05-06 (stable -- no moving targets, all frontend work against existing backend)