docs(22): create phase plan for add-from-catalog and thread integration

This commit is contained in:
2026-04-06 15:38:27 +02:00
parent 83b601bcf6
commit 2c1517032c
3 changed files with 751 additions and 2 deletions

View File

@@ -228,7 +228,10 @@ Plans:
1. User can add a catalog item to collection with one confirmation step (category picker + notes)
2. User can add catalog items as thread candidates instantly from search
3. Resolving a catalog-linked candidate creates a properly linked reference item in collection
**Plans**: TBD
**Plans:** 2 plans
Plans:
- [ ] 22-01-PLAN.md -- UIStore + sonner + AddToCollectionModal + overlay/detail page collection wiring
- [ ] 22-02-PLAN.md -- AddToThreadModal with thread picker + new thread flow + CATFLOW-06 verification
**UI hint**: yes
### Phase 23: Manual Entry Fallback
@@ -267,5 +270,5 @@ Plans:
| 19. Reference Item Model & Tags Schema | v2.0 | 3/3 | Complete | 2026-04-05 |
| 20. FAB & Full-Screen Catalog Search | v2.0 | 2/2 | Complete | 2026-04-06 |
| 21. Item & Catalog Detail Pages | v2.0 | 1/1 | Complete | 2026-04-06 |
| 22. Add-from-Catalog & Thread Integration | v2.0 | 0/? | Not started | - |
| 22. Add-from-Catalog & Thread Integration | v2.0 | 0/2 | Not started | - |
| 23. Manual Entry Fallback | v2.0 | 0/? | Not started | - |

View File

@@ -0,0 +1,384 @@
---
phase: 22-add-from-catalog-thread-integration
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/client/stores/uiStore.ts
- src/client/components/AddToCollectionModal.tsx
- src/client/components/CatalogSearchOverlay.tsx
- src/client/routes/global-items/$globalItemId.tsx
- src/client/routes/__root.tsx
autonomous: true
requirements: [CATFLOW-03]
must_haves:
truths:
- "Clicking Add on a catalog search card in collection mode opens the AddToCollectionModal"
- "AddToCollectionModal shows category dropdown, optional notes, optional purchase price, and submit/cancel buttons"
- "Submitting the modal creates a reference item with globalItemId and personal fields"
- "Success toast appears after adding item to collection"
- "Clicking Add to Collection on the global item detail page opens the same modal"
artifacts:
- path: "src/client/components/AddToCollectionModal.tsx"
provides: "Add-to-collection confirmation modal"
min_lines: 80
- path: "src/client/stores/uiStore.ts"
provides: "Modal state slices for addToCollectionModal and catalogSessionThreadId"
contains: "addToCollectionModal"
- path: "src/client/routes/__root.tsx"
provides: "Toaster and AddToCollectionModal rendered at root"
contains: "Toaster"
key_links:
- from: "src/client/components/CatalogSearchOverlay.tsx"
to: "src/client/stores/uiStore.ts"
via: "openAddToCollection call replacing handleAddStub"
pattern: "openAddToCollection"
- from: "src/client/components/AddToCollectionModal.tsx"
to: "/api/items"
via: "useCreateItem mutation with globalItemId"
pattern: "useCreateItem"
- from: "src/client/routes/global-items/$globalItemId.tsx"
to: "src/client/stores/uiStore.ts"
via: "openAddToCollection on button click"
pattern: "openAddToCollection"
---
<objective>
Wire the add-to-collection flow: install sonner for toasts, extend UIStore with modal states, build the AddToCollectionModal component, and replace the stub handler in CatalogSearchOverlay and global item detail page for collection mode.
Purpose: CATFLOW-03 -- users can add catalog items to their collection as reference items with personal fields (category, notes, purchase price).
Output: Working add-to-collection flow from both catalog search and global item detail page.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/22-add-from-catalog-thread-integration/22-CONTEXT.md
@.planning/phases/22-add-from-catalog-thread-integration/22-RESEARCH.md
<interfaces>
<!-- Key types and contracts the executor needs -->
From src/shared/schemas.ts:
```typescript
export const createItemSchema = z.object({
name: z.string().min(1, "Name is required"),
weightGrams: z.number().nonnegative().optional(),
priceCents: z.number().int().nonnegative().optional(),
categoryId: z.number().int().positive(),
notes: z.string().optional(),
productUrl: z.string().url().optional().or(z.literal("")),
imageFilename: z.string().optional(),
imageSourceUrl: z.string().url().optional().or(z.literal("")),
quantity: z.number().int().positive().optional(),
globalItemId: z.number().int().positive().optional(),
purchasePriceCents: z.number().int().nonnegative().optional(),
});
```
From src/client/hooks/useItems.ts:
```typescript
export function useCreateItem() // mutationFn: (data: CreateItem) => apiPost<ItemWithCategory>("/api/items", data)
// onSuccess invalidates ["items"] and ["totals"]
```
From src/client/hooks/useCategories.ts:
```typescript
export function useCategories() // queryKey: ["categories"], returns Category[]
```
From src/client/stores/uiStore.ts (existing pattern):
```typescript
catalogSearchOpen: boolean;
catalogSearchMode: "collection" | "thread" | null;
openCatalogSearch: (mode: "collection" | "thread") => void;
closeCatalogSearch: () => void;
```
From src/client/components/CreateThreadModal.tsx (modal pattern):
```typescript
// Modal pattern: fixed inset-0 z-50, bg-black/50 backdrop, onClick={handleClose}
// Inner div: w-full max-w-md bg-white rounded-xl shadow-xl p-6
// Form with local state, UIStore for open/close, mutation hook for submit
```
From CatalogSearchOverlay.tsx CardProps:
```typescript
interface CardProps {
item: {
id: number;
brand: string;
model: string;
category: string | null;
weightGrams: number | null;
priceCents: number | null;
imageUrl: string | null;
};
onAdd: (e: React.MouseEvent) => void;
onCardClick: () => void;
weight: (g: number) => string;
price: (cents: number) => string;
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: UIStore extension + sonner setup + AddToCollectionModal</name>
<files>
src/client/stores/uiStore.ts
src/client/components/AddToCollectionModal.tsx
src/client/routes/__root.tsx
</files>
<read_first>
src/client/stores/uiStore.ts
src/client/components/CreateThreadModal.tsx
src/client/hooks/useItems.ts
src/client/hooks/useCategories.ts
src/shared/schemas.ts
src/client/routes/__root.tsx
</read_first>
<action>
**Step 1: Install sonner**
```bash
bun add sonner
```
**Step 2: Extend UIStore** (`src/client/stores/uiStore.ts`)
Add to the `UIState` interface:
```typescript
// Add-to-collection modal (per D-20)
addToCollectionModal: { open: boolean; globalItemId: number | null; globalItemName: string | null };
openAddToCollection: (globalItemId: number, globalItemName: string) => void;
closeAddToCollection: () => void;
// Add-to-thread modal (per D-21)
addToThreadModal: { open: boolean; globalItemId: number | null; globalItemName: string | null };
openAddToThread: (globalItemId: number, globalItemName: string) => void;
closeAddToThread: () => void;
// Session thread tracking (per D-22)
catalogSessionThreadId: number | null;
setCatalogSessionThreadId: (id: number | null) => void;
```
Add to the `create` implementation:
```typescript
// Add-to-collection modal
addToCollectionModal: { open: false, globalItemId: null, globalItemName: null },
openAddToCollection: (globalItemId, globalItemName) =>
set({ addToCollectionModal: { open: true, globalItemId, globalItemName } }),
closeAddToCollection: () =>
set({ addToCollectionModal: { open: false, globalItemId: null, globalItemName: null } }),
// Add-to-thread modal
addToThreadModal: { open: false, globalItemId: null, globalItemName: null },
openAddToThread: (globalItemId, globalItemName) =>
set({ addToThreadModal: { open: true, globalItemId, globalItemName } }),
closeAddToThread: () =>
set({ addToThreadModal: { open: false, globalItemId: null, globalItemName: null } }),
// Session thread tracking
catalogSessionThreadId: null,
setCatalogSessionThreadId: (id) => set({ catalogSessionThreadId: id }),
```
Also update `closeCatalogSearch` to reset session thread (per D-22):
```typescript
closeCatalogSearch: () =>
set({ catalogSearchOpen: false, catalogSearchMode: null, catalogSessionThreadId: null }),
```
**Step 3: Create AddToCollectionModal** (`src/client/components/AddToCollectionModal.tsx`)
Follow CreateThreadModal pattern exactly (per D-05). Component structure:
- Read `addToCollectionModal` from UIStore (`open`, `globalItemId`, `globalItemName`)
- Read `closeAddToCollection` from UIStore
- Use `useCategories()` for category dropdown
- Use `useCreateItem()` for the mutation
- Local state: `categoryId` (number | null), `notes` (string), `purchasePriceCents` (number | undefined)
- Auto-match category: when categories load, find category where `c.name.toLowerCase() === globalItemCategory?.toLowerCase()`, fall back to `categories?.[0]?.id`. Since we only have `globalItemName` in UIStore (not category), skip auto-match for now -- default to first category.
- If `!open || !globalItemId` return null
- Render modal: `fixed inset-0 z-50 flex items-center justify-center bg-black/50`
- Inner form: `w-full max-w-md bg-white rounded-xl shadow-xl p-6`
- Header: "Add to Collection" h2
- Show item name as a subheading: `<p className="text-sm text-gray-500 mb-4">{globalItemName}</p>`
- Category dropdown (per D-02): `<select>` with categories, same pattern as CreateThreadModal
- Notes textarea (per D-02): `<textarea>` optional, placeholder "Personal notes (optional)"
- Purchase price field (per D-02): `<input type="number">` for price in dollars (display), convert to cents on submit by multiplying by 100 and rounding. Placeholder "Purchase price (optional)". Label "Purchase Price ($)".
- Submit button: "Add to Collection", disabled during `isPending`
- Cancel button: calls `closeAddToCollection()`
- On submit (per D-03): call `createItem.mutate({ name: globalItemName ?? "Unknown Item", categoryId, globalItemId, notes: notes || undefined, purchasePriceCents: purchasePriceCents || undefined })`
- On success (per D-04): call `toast.success("Added to Collection")` then `closeAddToCollection()`
- On error: show error message inline
- Reset form state when modal closes via `useEffect` watching `open`
Import `toast` from `sonner`.
**Step 4: Add Toaster and AddToCollectionModal to root layout** (`src/client/routes/__root.tsx`)
Add imports:
```typescript
import { Toaster } from "sonner";
import { AddToCollectionModal } from "../components/AddToCollectionModal";
```
In the RootLayout JSX, after the `<CatalogSearchOverlay />` line (around line 205), add:
```jsx
<AddToCollectionModal />
<Toaster position="bottom-right" richColors />
```
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun run build 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- `src/client/stores/uiStore.ts` contains `addToCollectionModal: { open: boolean; globalItemId: number | null; globalItemName: string | null }`
- `src/client/stores/uiStore.ts` contains `openAddToCollection:`
- `src/client/stores/uiStore.ts` contains `closeAddToCollection:`
- `src/client/stores/uiStore.ts` contains `addToThreadModal:`
- `src/client/stores/uiStore.ts` contains `catalogSessionThreadId: number | null`
- `src/client/stores/uiStore.ts` closeCatalogSearch resets `catalogSessionThreadId: null`
- `src/client/components/AddToCollectionModal.tsx` exists with `export function AddToCollectionModal`
- `AddToCollectionModal.tsx` contains `useCreateItem` import
- `AddToCollectionModal.tsx` contains `useCategories` import
- `AddToCollectionModal.tsx` contains `toast.success("Added to Collection")`
- `AddToCollectionModal.tsx` contains `globalItemId` in the mutate call
- `AddToCollectionModal.tsx` contains `purchasePriceCents`
- `src/client/routes/__root.tsx` contains `import { Toaster } from "sonner"`
- `src/client/routes/__root.tsx` contains `<AddToCollectionModal />`
- `src/client/routes/__root.tsx` contains `<Toaster`
- `bun run build` exits with code 0
</acceptance_criteria>
<done>UIStore has all Phase 22 modal states (addToCollectionModal, addToThreadModal, catalogSessionThreadId). AddToCollectionModal renders with category dropdown, notes, purchase price. Sonner Toaster is in root layout. Build passes.</done>
</task>
<task type="auto">
<name>Task 2: Wire CatalogSearchOverlay and global item detail page for collection mode</name>
<files>
src/client/components/CatalogSearchOverlay.tsx
src/client/routes/global-items/$globalItemId.tsx
</files>
<read_first>
src/client/components/CatalogSearchOverlay.tsx
src/client/routes/global-items/$globalItemId.tsx
src/client/stores/uiStore.ts
src/client/components/AddToCollectionModal.tsx
</read_first>
<action>
**Step 1: Update CatalogSearchOverlay** (`src/client/components/CatalogSearchOverlay.tsx`)
Replace the `handleAddStub` function (lines 111-114) with a proper handler (per D-16, D-17, D-18):
```typescript
const openAddToCollection = useUIStore((s) => s.openAddToCollection);
const openAddToThread = useUIStore((s) => s.openAddToThread);
function handleAdd(e: React.MouseEvent, item: { id: number; brand: string; model: string }) {
e.stopPropagation();
const itemName = `${item.brand} ${item.model}`;
if (catalogSearchMode === "collection") {
openAddToCollection(item.id, itemName);
} else if (catalogSearchMode === "thread") {
openAddToThread(item.id, itemName);
}
}
```
Update `onAdd` prop usage in both `GridCard` and `ListRow` renders. Currently:
```typescript
onAdd={handleAddStub}
```
Change to:
```typescript
onAdd={(e) => handleAdd(e, item)}
```
The `onAdd` prop type on `CardProps` stays `(e: React.MouseEvent) => void` -- the item data is captured in the closure.
**Step 2: Update global item detail page** (`src/client/routes/global-items/$globalItemId.tsx`)
Add imports:
```typescript
import { useUIStore } from "../../stores/uiStore";
```
Inside `GlobalItemDetail` function, add:
```typescript
const openAddToCollection = useUIStore((s) => s.openAddToCollection);
const openAddToThread = useUIStore((s) => s.openAddToThread);
```
Replace the existing "Add to Collection" button (line 131-135) that has `console.log`:
```jsx
<div className="flex gap-3 mb-6">
<button
type="button"
onClick={() => openAddToCollection(item.id, `${item.brand} ${item.model}`)}
className="bg-gray-700 text-white rounded-lg px-5 py-2.5 text-sm font-medium hover:bg-gray-800 transition-colors"
>
Add to Collection
</button>
<button
type="button"
onClick={() => openAddToThread(item.id, `${item.brand} ${item.model}`)}
className="bg-white text-gray-700 border border-gray-200 rounded-lg px-5 py-2.5 text-sm font-medium hover:bg-gray-50 transition-colors"
>
Add to Thread
</button>
</div>
```
Per D-14 and D-15: both buttons on the detail page. "Add to Collection" is primary (filled), "Add to Thread" is secondary (outlined).
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun run build 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- `src/client/components/CatalogSearchOverlay.tsx` does NOT contain `handleAddStub`
- `CatalogSearchOverlay.tsx` contains `openAddToCollection`
- `CatalogSearchOverlay.tsx` contains `openAddToThread`
- `CatalogSearchOverlay.tsx` contains `catalogSearchMode === "collection"`
- `CatalogSearchOverlay.tsx` contains `catalogSearchMode === "thread"`
- `src/client/routes/global-items/$globalItemId.tsx` does NOT contain `console.log`
- `$globalItemId.tsx` contains `openAddToCollection(item.id`
- `$globalItemId.tsx` contains `openAddToThread(item.id`
- `$globalItemId.tsx` contains `Add to Thread`
- `$globalItemId.tsx` imports `useUIStore`
- `bun run build` exits with code 0
</acceptance_criteria>
<done>CatalogSearchOverlay dispatches to correct modal based on catalogSearchMode. Global item detail page has both "Add to Collection" and "Add to Thread" buttons wired to UIStore. handleAddStub is fully replaced. No console.log stubs remain.</done>
</task>
</tasks>
<verification>
1. `bun run build` passes with no type errors
2. In collection mode, clicking Add on catalog card opens AddToCollectionModal
3. Submitting modal with category creates a reference item (POST /api/items with globalItemId)
4. Toast "Added to Collection" appears after successful add
5. Global item detail page shows both "Add to Collection" and "Add to Thread" buttons
</verification>
<success_criteria>
- CATFLOW-03 is functional: user can add catalog item to collection with category picker + notes + purchase price
- AddToCollectionModal creates reference items via existing useCreateItem hook with globalItemId
- Sonner toast system operational for success feedback
- Both entry points (catalog search overlay + global item detail page) wire to the modal
- "Add to Thread" button exists on detail page (wired to UIStore, modal built in Plan 02)
</success_criteria>
<output>
After completion, create `.planning/phases/22-add-from-catalog-thread-integration/22-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,362 @@
---
phase: 22-add-from-catalog-thread-integration
plan: 02
type: execute
wave: 2
depends_on: ["22-01"]
files_modified:
- src/client/components/AddToThreadModal.tsx
- src/client/routes/__root.tsx
autonomous: false
requirements: [CATFLOW-05, CATFLOW-06]
must_haves:
truths:
- "Clicking Add on a catalog search card in thread mode opens the AddToThreadModal with a thread picker"
- "User can select an existing active thread and the catalog item is added as a candidate"
- "User can choose New Thread which shows thread name + category fields and creates thread + candidate in one step"
- "After creating a new thread, subsequent adds in same session default to that thread"
- "Clicking Add to Thread on the global item detail page opens the same thread picker modal"
- "Resolving a thread with a catalog-linked candidate creates a reference item (already works)"
artifacts:
- path: "src/client/components/AddToThreadModal.tsx"
provides: "Thread picker modal with new thread creation flow"
min_lines: 120
- path: "src/client/routes/__root.tsx"
provides: "AddToThreadModal rendered at root level"
contains: "AddToThreadModal"
key_links:
- from: "src/client/components/AddToThreadModal.tsx"
to: "/api/threads"
via: "useCreateThread for new thread creation"
pattern: "useCreateThread"
- from: "src/client/components/AddToThreadModal.tsx"
to: "/api/threads/:threadId/candidates"
via: "apiPost for candidate creation after thread selection/creation"
pattern: "apiPost.*candidates"
- from: "src/client/components/AddToThreadModal.tsx"
to: "src/client/stores/uiStore.ts"
via: "setCatalogSessionThreadId to remember thread selection"
pattern: "setCatalogSessionThreadId"
---
<objective>
Build the AddToThreadModal: thread picker with existing active threads, "New Thread..." option for combined thread+candidate creation, and session thread memory. Wire to root layout. Verify CATFLOW-06 (thread resolution with catalog-linked candidates).
Purpose: CATFLOW-05 -- users can add catalog items as thread candidates from search. CATFLOW-06 -- resolution already works, verify it.
Output: Working add-to-thread flow, complete Phase 22 catalog integration.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/22-add-from-catalog-thread-integration/22-CONTEXT.md
@.planning/phases/22-add-from-catalog-thread-integration/22-RESEARCH.md
@.planning/phases/22-add-from-catalog-thread-integration/22-01-SUMMARY.md
<interfaces>
<!-- Types and contracts from Plan 01 and existing code -->
From src/client/stores/uiStore.ts (after Plan 01):
```typescript
addToThreadModal: { open: boolean; globalItemId: number | null; globalItemName: string | null };
openAddToThread: (globalItemId: number, globalItemName: string) => void;
closeAddToThread: () => void;
catalogSessionThreadId: number | null;
setCatalogSessionThreadId: (id: number | null) => void;
```
From src/client/hooks/useThreads.ts:
```typescript
export function useThreads(includeResolved = false)
// Returns ThreadListItem[] with: id, name, status, categoryId, categoryName, candidateCount
export function useCreateThread()
// mutationFn: (data: { name: string; categoryId: number }) => apiPost<ThreadListItem>("/api/threads", data)
// onSuccess invalidates ["threads"]
```
From src/client/hooks/useCandidates.ts:
```typescript
export function useCreateCandidate(threadId: number)
// mutationFn: (data: CreateCandidate & { imageFilename?: string }) => apiPost(...)
// NOTE: threadId is a hook parameter, not in mutation payload
// For dynamic threadId (new thread flow), use apiPost directly instead
```
From src/shared/schemas.ts:
```typescript
export const createCandidateSchema = z.object({
name: z.string().min(1, "Name is required"),
weightGrams: z.number().nonnegative().optional(),
priceCents: z.number().int().nonnegative().optional(),
categoryId: z.number().int().positive(),
notes: z.string().optional(),
productUrl: z.string().url().optional().or(z.literal("")),
imageFilename: z.string().optional(),
imageSourceUrl: z.string().url().optional().or(z.literal("")),
status: candidateStatusSchema.optional(),
pros: z.string().optional(),
cons: z.string().optional(),
globalItemId: z.number().int().positive().optional(),
});
```
From src/client/lib/api.ts:
```typescript
export async function apiPost<T>(url: string, body: unknown): Promise<T>
```
From src/client/hooks/useGlobalItems.ts:
```typescript
export function useGlobalItem(id: number)
// Returns globalItem with: id, brand, model, category, weightGrams, priceCents, imageUrl, description, ownerCount
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Build AddToThreadModal with thread picker and new thread flow</name>
<files>
src/client/components/AddToThreadModal.tsx
src/client/routes/__root.tsx
</files>
<read_first>
src/client/stores/uiStore.ts
src/client/components/CreateThreadModal.tsx
src/client/components/AddToCollectionModal.tsx
src/client/hooks/useThreads.ts
src/client/hooks/useCandidates.ts
src/client/hooks/useGlobalItems.ts
src/client/hooks/useCategories.ts
src/shared/schemas.ts
src/client/lib/api.ts
src/client/routes/__root.tsx
</read_first>
<action>
**Create `src/client/components/AddToThreadModal.tsx`**
This modal has two modes: "pick" (select existing thread) and "create" (new thread + candidate).
**Component structure:**
```typescript
import { useState, useEffect } from "react";
import { toast } from "sonner";
import { useCategories } from "../hooks/useCategories";
import { useGlobalItem } from "../hooks/useGlobalItems";
import { useCreateThread, useThreads } from "../hooks/useThreads";
import { apiPost } from "../lib/api";
import { useUIStore } from "../stores/uiStore";
import { useQueryClient } from "@tanstack/react-query";
```
**State management:**
- Read from UIStore: `addToThreadModal` (open, globalItemId, globalItemName), `closeAddToThread`, `catalogSessionThreadId`, `setCatalogSessionThreadId`
- Local state: `mode` ("pick" | "create"), `selectedThreadId` (number | null), `newThreadName` (string), `newThreadCategoryId` (number | null), `isSubmitting` (boolean), `error` (string | null)
- Fetch: `useThreads()` for thread list, `useCategories()` for new thread category dropdown, `useGlobalItem(globalItemId)` for the global item data needed to create the candidate
- `useCreateThread()` for thread creation, `useQueryClient()` for manual invalidation
**Initialization logic (per D-12, D-19):**
- When modal opens (`open` becomes true), if `catalogSessionThreadId` is set, pre-select it in the thread picker (`selectedThreadId = catalogSessionThreadId`)
- Filter threads to active only: `threads?.filter((t) => t.status === "active") ?? []`
- If no active threads exist, auto-switch to "create" mode (per D-09 empty state)
**"Pick" mode UI (per D-06, D-07):**
- Header: "Add to Thread"
- Subheading: show `globalItemName` in `<p className="text-sm text-gray-500 mb-4">`
- Thread dropdown `<select>`:
- Each active thread as `<option value={t.id}>{t.name} ({t.categoryName})</option>` -- show category alongside name per discretion
- Last option: `<option value="new">+ New Thread...</option>`
- When "new" is selected, switch `mode` to "create"
- Submit button: "Add as Candidate", disabled during `isSubmitting`
- Cancel button: calls `closeAddToThread()`
**"Create" mode UI (per D-11, D-13):**
- Header: "New Thread + Candidate"
- Subheading: show `globalItemName`
- Thread name input: same as CreateThreadModal
- Category dropdown: same as CreateThreadModal (from `useCategories()`)
- Submit button: "Create & Add", disabled during `isSubmitting`
- Cancel button: goes back to "pick" mode if active threads exist, otherwise calls `closeAddToThread()`
- Back link: "Back to thread picker" if active threads exist
**Submit handler for "pick" mode:**
```typescript
async function handleAddToExistingThread() {
if (!selectedThreadId || !globalItemId) return;
setIsSubmitting(true);
setError(null);
try {
const thread = activeThreads.find((t) => t.id === selectedThreadId);
await apiPost(`/api/threads/${selectedThreadId}/candidates`, {
name: globalItemName ?? "Unknown Item",
globalItemId,
categoryId: thread?.categoryId ?? categories?.[0]?.id ?? 1,
weightGrams: globalItem?.weightGrams ?? undefined,
priceCents: globalItem?.priceCents ?? undefined,
});
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["threads", selectedThreadId] });
setCatalogSessionThreadId(selectedThreadId);
toast.success(`Added to "${thread?.name ?? "thread"}"`);
closeAddToThread();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to add candidate");
} finally {
setIsSubmitting(false);
}
}
```
Use `apiPost` directly instead of `useCreateCandidate` hook because `useCreateCandidate(threadId)` requires threadId at hook initialization time. For the "pick" flow the threadId changes with selection. Using `apiPost` directly avoids this pitfall (per RESEARCH.md Pitfall 1).
**Submit handler for "create" mode (per D-11):**
```typescript
async function handleCreateThreadAndAdd() {
const trimmedName = newThreadName.trim();
if (!trimmedName || !newThreadCategoryId || !globalItemId) return;
setIsSubmitting(true);
setError(null);
try {
const thread = await createThread.mutateAsync({ name: trimmedName, categoryId: newThreadCategoryId });
await apiPost(`/api/threads/${thread.id}/candidates`, {
name: globalItemName ?? "Unknown Item",
globalItemId,
categoryId: newThreadCategoryId,
weightGrams: globalItem?.weightGrams ?? undefined,
priceCents: globalItem?.priceCents ?? undefined,
});
queryClient.invalidateQueries({ queryKey: ["threads"] });
setCatalogSessionThreadId(thread.id);
toast.success(`Created "${trimmedName}" with first candidate`);
closeAddToThread();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create thread");
} finally {
setIsSubmitting(false);
}
}
```
**Form reset:** `useEffect` watching `open` -- when it becomes false, reset all local state (mode back to "pick", clear name, selectedThreadId, error).
**Modal rendering:** Follow CreateThreadModal pattern:
- `fixed inset-0 z-50 flex items-center justify-center bg-black/50`
- onClick backdrop = close
- Escape key = close
- Inner: `w-full max-w-md bg-white rounded-xl shadow-xl p-6`, `e.stopPropagation()`
**Add to root layout** (`src/client/routes/__root.tsx`):
Add import:
```typescript
import { AddToThreadModal } from "../components/AddToThreadModal";
```
Add `<AddToThreadModal />` right after `<AddToCollectionModal />` in the JSX.
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun run build 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- `src/client/components/AddToThreadModal.tsx` exists with `export function AddToThreadModal`
- `AddToThreadModal.tsx` contains `useThreads` import
- `AddToThreadModal.tsx` contains `useCreateThread` import
- `AddToThreadModal.tsx` contains `useGlobalItem` import
- `AddToThreadModal.tsx` contains `apiPost` import from `../lib/api`
- `AddToThreadModal.tsx` contains `setCatalogSessionThreadId`
- `AddToThreadModal.tsx` contains `catalogSessionThreadId`
- `AddToThreadModal.tsx` contains `toast.success`
- `AddToThreadModal.tsx` contains `globalItemId` in the apiPost call body
- `AddToThreadModal.tsx` contains `mode` state with "pick" and "create" values
- `AddToThreadModal.tsx` contains `+ New Thread...` option text
- `src/client/routes/__root.tsx` contains `import { AddToThreadModal }`
- `src/client/routes/__root.tsx` contains `<AddToThreadModal />`
- `bun run build` exits with code 0
</acceptance_criteria>
<done>AddToThreadModal supports picking an existing active thread and creating a new thread with first candidate. Session thread tracking persists across adds. Both catalog search overlay and global item detail page wire to this modal via UIStore. Build passes.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 2: Verify complete add-from-catalog flows</name>
<what-built>
Complete add-from-catalog and add-to-thread flows wired through catalog search overlay and global item detail pages. Two modal components (AddToCollectionModal, AddToThreadModal) with toast notifications.
</what-built>
<how-to-verify>
Run `bun run dev` and test in browser at http://localhost:5173:
**Flow 1: Add to Collection from catalog search**
1. Click the FAB (floating action button)
2. Select "Add to Collection"
3. Catalog search overlay opens with "Adding to Collection" context
4. Search for an item and click "Add" on a card
5. Verify: AddToCollectionModal opens with category dropdown, notes field, purchase price field
6. Select a category, optionally fill notes/price, click "Add to Collection"
7. Verify: toast "Added to Collection" appears, modal closes, overlay stays open
8. Verify: item appears in collection (navigate to /collection to check)
**Flow 2: Add to Collection from global item detail page**
1. Click a catalog card to navigate to its detail page (`/global-items/:id`)
2. Verify: both "Add to Collection" and "Add to Thread" buttons are visible
3. Click "Add to Collection"
4. Verify: same modal opens, submit works, toast appears
**Flow 3: Add to Thread from catalog search (existing thread)**
1. Ensure at least one active thread exists (create one via Planning tab if needed)
2. Click FAB > "Start Thread"
3. Search for an item and click "Add"
4. Verify: thread picker modal opens listing active threads with category names
5. Select a thread and click "Add as Candidate"
6. Verify: toast "Added to [Thread Name]" appears
7. Click "Add" on another item -- verify the previously selected thread is pre-selected
**Flow 4: New Thread creation from thread picker**
1. In thread mode, click "Add" on a catalog card
2. In the thread picker dropdown, select "+ New Thread..."
3. Verify: form switches to show thread name + category fields
4. Fill in thread name, select category, click "Create & Add"
5. Verify: toast "Created [name] with first candidate" appears
6. Click "Add" on another card -- verify the new thread is pre-selected (session memory)
**Flow 5: Add to Thread from global item detail page**
1. Navigate to a global item detail page
2. Click "Add to Thread"
3. Verify: thread picker modal opens, same as Flow 3
**Flow 6: Thread resolution (CATFLOW-06 -- existing behavior)**
1. Go to a thread that has a catalog-linked candidate (from Flow 3/4)
2. Resolve the thread by selecting that candidate
3. Verify: a new item appears in collection with the global item data
</how-to-verify>
<resume-signal>Type "approved" or describe any issues found</resume-signal>
</task>
</tasks>
<verification>
1. `bun run build` passes with no type errors
2. `bun test` passes (no backend changes, existing tests should remain green)
3. Add-to-collection flow works from both entry points
4. Add-to-thread flow works with existing threads and new thread creation
5. Session thread memory works within a search session
6. Thread resolution with catalog-linked candidate creates reference item
</verification>
<success_criteria>
- CATFLOW-05 is functional: user can add catalog items as thread candidates from search
- CATFLOW-06 is verified: resolving a catalog-linked candidate creates a reference item
- AddToThreadModal supports existing thread selection AND new thread + candidate creation
- Session thread tracking remembers selection within a catalog search session
- All flows accessible from both catalog search overlay and global item detail page
</success_criteria>
<output>
After completion, create `.planning/phases/22-add-from-catalog-thread-integration/22-02-SUMMARY.md`
</output>