Files
GearBox/.planning/phases/22-add-from-catalog-thread-integration/22-RESEARCH.md

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 + globalItemName in UIStore. Fetch full data in the modal via useGlobalItem(id) if needed, or pass additional fields as modal props.
  • Don't create a new useCreateCandidate variant: The existing hook takes threadId as parameter -- use it directly. For the combined flow, call useCreateThread().mutateAsync() first to get the thread ID.
  • Don't modify backend routes or schemas: Everything needed is already in place. createItemSchema has globalItemId, createCandidateSchema has globalItemId.

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 -- confirmed globalItemId on both createItemSchema and createCandidateSchema
  • src/server/services/item.service.ts -- confirmed reference item creation pattern
  • src/server/services/thread.service.ts:312+ -- confirmed catalog-linked resolution branch
  • src/client/stores/uiStore.ts -- confirmed existing modal state patterns
  • src/client/components/CreateThreadModal.tsx -- confirmed modal component pattern
  • src/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)