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

363 lines
16 KiB
Markdown

---
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>