docs(22): create phase plan for add-from-catalog and thread integration
This commit is contained in:
@@ -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 | - |
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user