docs(22): research phase domain
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
# 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:**
|
||||
```bash
|
||||
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:**
|
||||
```typescript
|
||||
// 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:**
|
||||
```typescript
|
||||
// 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.
|
||||
```typescript
|
||||
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:**
|
||||
```typescript
|
||||
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
|
||||
```typescript
|
||||
// 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
|
||||
```typescript
|
||||
// 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
|
||||
```typescript
|
||||
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)
|
||||
Reference in New Issue
Block a user