From 91c16b9b3cdcf7983d75fd4dd566f2a1a43f7aa9 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 15 Mar 2026 11:24:28 +0100 Subject: [PATCH] docs(phase-2): research planning threads domain --- .../phases/02-planning-threads/02-RESEARCH.md | 606 ++++++++++++++++++ 1 file changed, 606 insertions(+) create mode 100644 .planning/phases/02-planning-threads/02-RESEARCH.md diff --git a/.planning/phases/02-planning-threads/02-RESEARCH.md b/.planning/phases/02-planning-threads/02-RESEARCH.md new file mode 100644 index 0000000..5105c89 --- /dev/null +++ b/.planning/phases/02-planning-threads/02-RESEARCH.md @@ -0,0 +1,606 @@ +# Phase 2: Planning Threads - Research + +**Researched:** 2026-03-15 +**Domain:** Planning thread CRUD, candidate management, thread resolution with collection integration +**Confidence:** HIGH + +## Summary + +Phase 2 extends the established Phase 1 stack (Hono + Drizzle + React + TanStack Router/Query) with two new database tables (`threads` and `thread_candidates`), corresponding service layers, API routes, and frontend components. The core architectural challenge is the thread resolution flow: when a user picks a winning candidate, the system must atomically create a collection item from the candidate's data and archive the thread. + +The existing codebase provides strong reuse opportunities. Candidates share the exact same fields as collection items (name, weight, price, category, notes, product link, image), so the `ItemForm`, `ItemCard`, `SlideOutPanel`, `ConfirmDialog`, `CategoryPicker`, and `ImageUpload` components can all be reused or lightly adapted. The service layer pattern (DB injection, Drizzle queries) and API route pattern (Hono + Zod validation) are well-established from Phase 1 and should be replicated exactly. + +Navigation is tab-based: "My Gear" and "Planning" tabs within the same page structure. TanStack Router supports this via either search params or nested routes. The thread list is the "Planning" tab; clicking a thread navigates to a thread detail view showing its candidates. + +**Primary recommendation:** Follow Phase 1 patterns exactly. New tables for threads and candidates, new service/route/hook layers mirroring items. Resolution is a single transactional operation in the thread service that creates an item and archives the thread. + + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions +- Card-based layout, same visual pattern as collection items +- Thread card shows: name prominent, then pill/chip tags for candidate count, creation date, price range +- Flat list, most recent first (no grouping) +- Resolved/archived threads hidden by default with a toggle to show them +- Candidates displayed as card grid within a thread (same card style as collection items) +- Slide-out panel for adding/editing candidates (reuses existing SlideOutPanel component) +- Candidates share the exact same fields as collection items: name, weight, price, category, notes, product link, image +- Same data shape means resolution is seamless -- candidate data maps directly to a collection item +- Picking a winner auto-creates a collection item from the candidate's data (no review/edit step) +- Confirmation dialog before resolving ("Pick [X] as winner? This will add it to your collection.") +- After resolution, thread is archived (removed from active list, kept in history) +- Confirmation dialog reuses the existing ConfirmDialog component pattern +- Tab within the collection page: "My Gear" | "Planning" tabs +- Top navigation bar always visible for switching between major sections +- Thread list and collection share the same page with tab-based switching + +### Claude's Discretion +- Exact "pick winner" UX (button on card vs thread-level action) +- Thread detail page layout (how the thread view is structured beyond the card grid) +- Empty state for threads (no threads yet) and empty thread (no candidates yet) +- How the tab switching integrates with TanStack Router (query params vs nested routes) +- Thread card image (first candidate's image, thread-specific image, or none) + +### Deferred Ideas (OUT OF SCOPE) +- Linking existing collection items as reference candidates in a thread -- nice-to-have, not v1 +- Side-by-side comparison view (columns instead of cards) -- could be v2 enhancement (THRD-05) +- Status tracking on candidates (researching -> ordered -> arrived) -- v2 (THRD-06) +- Impact preview showing how a candidate affects setup weight/cost -- v2 (THRD-08) + + + + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|-----------------| +| THRD-01 | User can create a planning thread with a name | New `threads` table, thread service `createThread()`, POST /api/threads endpoint, thread creation UI (inline input or slide-out) | +| THRD-02 | User can add candidate products to a thread with weight, price, notes, and product link | New `thread_candidates` table with same fields as items + threadId FK, candidate service, POST /api/threads/:id/candidates, reuse ItemForm with minor adaptations | +| THRD-03 | User can edit and remove candidates from a thread | PUT/DELETE /api/threads/:threadId/candidates/:id, reuse SlideOutPanel + adapted ItemForm for edit, ConfirmDialog pattern for delete | +| THRD-04 | User can resolve a thread by picking a winner, which moves to their collection | `resolveThread()` service function: transactionally create item from candidate data + set thread status to "resolved", ConfirmDialog for confirmation, cache invalidation for both threads and items queries | + + + +## Standard Stack + +### Core (Already Installed from Phase 1) +| Library | Version | Purpose | Phase 2 Usage | +|---------|---------|---------|---------------| +| Hono | 4.12.x | Backend API | New thread + candidate route handlers | +| Drizzle ORM | 0.45.x | Database ORM | New table definitions, migration, transactional resolution | +| TanStack Router | 1.x | Client routing | Tab navigation, thread detail route | +| TanStack Query | 5.x | Server state | useThreads, useCandidates hooks | +| Zustand | 5.x | UI state | Thread panel state, confirm dialog state | +| Zod | 4.x | Validation | Thread and candidate schemas | +| @hono/zod-validator | 0.7.6+ | Route validation | Validate thread/candidate request bodies | + +### No New Dependencies Required + +Phase 2 uses the exact same stack as Phase 1. No new libraries needed. + +## Architecture Patterns + +### New Files Structure +``` +src/ + db/ + schema.ts # ADD: threads + thread_candidates tables + shared/ + schemas.ts # ADD: thread + candidate Zod schemas + types.ts # ADD: Thread, Candidate types + server/ + index.ts # ADD: mount thread routes + routes/ + threads.ts # NEW: /api/threads CRUD + resolution + services/ + thread.service.ts # NEW: thread + candidate business logic + client/ + routes/ + index.tsx # MODIFY: add tab navigation, move collection into tab + threads/ + index.tsx # NEW: thread detail view (or use search params) + components/ + ThreadCard.tsx # NEW: thread card for thread list + CandidateCard.tsx # NEW: candidate card (adapts ItemCard pattern) + CandidateForm.tsx # NEW: candidate add/edit form (adapts ItemForm) + ThreadTabs.tsx # NEW: tab switcher component + hooks/ + useThreads.ts # NEW: thread CRUD hooks + useCandidates.ts # NEW: candidate CRUD + resolution hooks + stores/ + uiStore.ts # MODIFY: add thread-specific panel/dialog state +tests/ + helpers/ + db.ts # MODIFY: add threads + candidates table creation + services/ + thread.service.test.ts # NEW: thread + candidate service tests + routes/ + threads.test.ts # NEW: thread API integration tests +``` + +### Pattern 1: Database Schema for Threads and Candidates + +**What:** Two new tables -- `threads` for the planning thread metadata and `thread_candidates` for candidate products within a thread. Candidates mirror the items table structure for seamless resolution. + +**Why this shape:** Candidates have the exact same fields as items (per CONTEXT.md locked decision). This makes resolution trivial: copy candidate fields to create a new item. The `status` field on threads supports the active/resolved lifecycle. + +```typescript +// Addition to src/db/schema.ts +export const threads = sqliteTable("threads", { + id: integer("id").primaryKey({ autoIncrement: true }), + name: text("name").notNull(), + status: text("status").notNull().default("active"), // "active" | "resolved" + resolvedCandidateId: integer("resolved_candidate_id"), // FK set on resolution + createdAt: integer("created_at", { mode: "timestamp" }).notNull() + .$defaultFn(() => new Date()), + updatedAt: integer("updated_at", { mode: "timestamp" }).notNull() + .$defaultFn(() => new Date()), +}); + +export const threadCandidates = sqliteTable("thread_candidates", { + id: integer("id").primaryKey({ autoIncrement: true }), + threadId: integer("thread_id").notNull() + .references(() => threads.id, { onDelete: "cascade" }), + name: text("name").notNull(), + weightGrams: real("weight_grams"), + priceCents: integer("price_cents"), + categoryId: integer("category_id").notNull() + .references(() => categories.id), + notes: text("notes"), + productUrl: text("product_url"), + imageFilename: text("image_filename"), + createdAt: integer("created_at", { mode: "timestamp" }).notNull() + .$defaultFn(() => new Date()), + updatedAt: integer("updated_at", { mode: "timestamp" }).notNull() + .$defaultFn(() => new Date()), +}); +``` + +**Key decisions:** +- `onDelete: "cascade"` on threadId FK: deleting a thread removes its candidates (threads are self-contained units) +- `resolvedCandidateId` on threads: records which candidate won (for display in archived view) +- `status` as text, not boolean: allows future states without migration (though only "active"/"resolved" for v1) +- Candidate fields exactly mirror items fields: enables direct data copy on resolution + +### Pattern 2: Thread Resolution as Atomic Transaction + +**What:** Resolving a thread is a single transactional operation: create a collection item from the winning candidate's data, then set the thread status to "resolved" and record the winning candidate ID. + +**Why transaction:** If either step fails, neither should persist. A resolved thread without the corresponding item (or vice versa) would be an inconsistent state. + +```typescript +// In thread.service.ts +export function resolveThread(db: Db = prodDb, threadId: number, candidateId: number) { + return db.transaction(() => { + // 1. Get the candidate data + const candidate = db.select().from(threadCandidates) + .where(eq(threadCandidates.id, candidateId)) + .get(); + if (!candidate) return { success: false, error: "Candidate not found" }; + if (candidate.threadId !== threadId) return { success: false, error: "Candidate not in thread" }; + + // 2. Check thread is active + const thread = db.select().from(threads) + .where(eq(threads.id, threadId)) + .get(); + if (!thread || thread.status !== "active") return { success: false, error: "Thread not active" }; + + // 3. Create collection item from candidate data + const newItem = db.insert(items).values({ + name: candidate.name, + weightGrams: candidate.weightGrams, + priceCents: candidate.priceCents, + categoryId: candidate.categoryId, + notes: candidate.notes, + productUrl: candidate.productUrl, + imageFilename: candidate.imageFilename, + }).returning().get(); + + // 4. Archive the thread + db.update(threads).set({ + status: "resolved", + resolvedCandidateId: candidateId, + updatedAt: new Date(), + }).where(eq(threads.id, threadId)).run(); + + return { success: true, item: newItem }; + }); +} +``` + +### Pattern 3: Tab Navigation with TanStack Router + +**What:** The collection and planning views share the same page with tab switching. Use search params (`?tab=planning`) for tab state -- this keeps a single route file and avoids unnecessary nesting. + +**Why search params over nested routes:** Tabs are lightweight view switches, not distinct pages with their own data loading. Search params are simpler and keep the URL shareable. + +```typescript +// In src/client/routes/index.tsx +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; + +const searchSchema = z.object({ + tab: z.enum(["gear", "planning"]).catch("gear"), +}); + +export const Route = createFileRoute("/")({ + validateSearch: searchSchema, + component: HomePage, +}); + +function HomePage() { + const { tab } = Route.useSearch(); + const navigate = Route.useNavigate(); + + return ( +
+ navigate({ search: { tab: t } })} + /> + {tab === "gear" ? : } +
+ ); +} +``` + +**Thread detail view:** When clicking a thread card, navigate to `/threads/$threadId` (a separate file-based route). This is a distinct page, not a tab -- it shows the thread's candidates. + +``` +src/client/routes/ + index.tsx # Home with tabs (gear/planning) + threads/ + $threadId.tsx # Thread detail: shows candidates +``` + +### Pattern 4: Reusing ItemForm for Candidates + +**What:** The candidate form shares the same fields as the item form. Rather than duplicating, adapt ItemForm to accept a `variant` prop or create a thin CandidateForm wrapper that uses the same field layout. + +**Recommended approach:** Create a `CandidateForm` that is structurally similar to `ItemForm` but posts to the candidate API endpoint. The form fields (name, weight, price, category, notes, productUrl, image) are identical. + +**Why not directly reuse ItemForm:** The form currently calls `useCreateItem`/`useUpdateItem` hooks internally and closes the panel via `useUIStore`. The candidate form needs different hooks and different store actions. A new component with the same field layout is cleaner than over-parameterizing ItemForm. + +### Anti-Patterns to Avoid + +- **Duplicating candidate data on resolution:** Copy candidate fields to a new item row. Do NOT try to "move" the candidate row or create a foreign key from items to candidates. The item should be independent once created. +- **Deleting thread on resolution:** Archive (set status="resolved"), do not delete. Users need to see their decision history. +- **Shared mutable state between tabs:** Each tab's data (items vs threads) should use separate TanStack Query keys. Tab switching should not trigger unnecessary refetches. +- **Over-engineering the ConfirmDialog:** The existing ConfirmDialog is hardcoded to item deletion. For thread resolution, create a new `ResolveDialog` component (or make a generic ConfirmDialog). Do not try to make the existing ConfirmDialog handle both deletion and resolution through complex state. + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Tab routing state | Manual useState for tabs | TanStack Router search params with `validateSearch` | URL-shareable, back-button works, type-safe | +| Atomic resolution | Manual multi-step API calls | Drizzle `db.transaction()` | Guarantees consistency: either both item creation and thread archival succeed, or neither does | +| Cache invalidation on resolution | Manual refetch calls | TanStack Query `invalidateQueries` for both `["items"]` and `["threads"]` keys | Ensures all views are fresh after resolution | +| Price range display on thread cards | Custom min/max computation in component | SQL aggregate in the query (or compute from loaded candidates) | Keep computation close to data source | + +**Key insight:** Resolution is the only genuinely new pattern in this phase. Everything else (CRUD services, Hono routes, TanStack Query hooks, slide-out panels) is a direct replication of Phase 1 patterns with different table/entity names. + +## Common Pitfalls + +### Pitfall 1: Orphaned Candidate Images on Thread Delete +**What goes wrong:** Deleting a thread cascades to delete candidates in the DB, but their uploaded images remain on disk. +**Why it happens:** CASCADE handles DB cleanup but not filesystem cleanup. +**How to avoid:** Before deleting a thread, query all its candidates, collect imageFilenames, delete the thread (cascade handles DB), then unlink image files. Wrap file cleanup in try/catch. +**Warning signs:** Orphaned files in uploads/ directory. + +### Pitfall 2: Resolution Creates Item with Wrong Category +**What goes wrong:** Candidate references a categoryId that was deleted between candidate creation and resolution. +**Why it happens:** Category deletion reassigns items to Uncategorized (id=1) but does NOT reassign candidates. +**How to avoid:** In the resolution transaction, verify the candidate's categoryId still exists. If not, fall back to categoryId=1 (Uncategorized). Alternatively, add the same FK constraint behavior to candidates. +**Warning signs:** FK constraint violation on resolution INSERT. + +### Pitfall 3: Image File Sharing Between Candidate and Resolved Item +**What goes wrong:** Resolution copies the candidate's `imageFilename` to the new item. If the thread is later deleted (cascade deletes candidates), the image cleanup logic might delete the file that the item still references. +**How to avoid:** On resolution, copy the image file to a new filename (e.g., append a suffix or generate new UUID). The item gets its own independent copy. Alternatively, skip image deletion on thread/candidate delete if the filename is referenced by an item. +**Warning signs:** Broken images on collection items that were created via thread resolution. + +### Pitfall 4: Stale Tab Data After Resolution +**What goes wrong:** User resolves a thread on the Planning tab, then switches to My Gear tab and doesn't see the new item. +**Why it happens:** Resolution mutation only invalidates `["threads"]` query key, not `["items"]` and `["totals"]`. +**How to avoid:** Resolution mutation's `onSuccess` must invalidate ALL affected query keys: `["threads"]`, `["items"]`, `["totals"]`. +**Warning signs:** New item only appears after manual page refresh. + +### Pitfall 5: Thread Detail Route Without Back Navigation +**What goes wrong:** User navigates to `/threads/5` but has no obvious way to get back to the planning list. +**Why it happens:** Thread detail is a separate route, and the tab bar is on the home page. +**How to avoid:** Thread detail page should have a back link/button to `/?tab=planning`. The top navigation bar (per locked decision) should always be visible. +**Warning signs:** User gets "stuck" on thread detail page. + +## Code Examples + +### Shared Zod Schemas for Threads and Candidates + +```typescript +// Additions to src/shared/schemas.ts + +export const createThreadSchema = z.object({ + name: z.string().min(1, "Thread name is required"), +}); + +export const updateThreadSchema = z.object({ + name: z.string().min(1).optional(), +}); + +// Candidates share the same fields as items +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("")), +}); + +export const updateCandidateSchema = createCandidateSchema.partial(); + +export const resolveThreadSchema = z.object({ + candidateId: z.number().int().positive(), +}); +``` + +### Thread Service Pattern (following item.service.ts) + +```typescript +// src/server/services/thread.service.ts +import { eq, desc, sql } from "drizzle-orm"; +import { threads, threadCandidates, items, categories } from "../../db/schema.ts"; +import { db as prodDb } from "../../db/index.ts"; + +type Db = typeof prodDb; + +export function getAllThreads(db: Db = prodDb, includeResolved = false) { + const query = db + .select({ + id: threads.id, + name: threads.name, + status: threads.status, + resolvedCandidateId: threads.resolvedCandidateId, + createdAt: threads.createdAt, + updatedAt: threads.updatedAt, + candidateCount: sql`( + SELECT COUNT(*) FROM thread_candidates + WHERE thread_id = ${threads.id} + )`, + minPriceCents: sql`( + SELECT MIN(price_cents) FROM thread_candidates + WHERE thread_id = ${threads.id} + )`, + maxPriceCents: sql`( + SELECT MAX(price_cents) FROM thread_candidates + WHERE thread_id = ${threads.id} + )`, + }) + .from(threads) + .orderBy(desc(threads.createdAt)); + + if (!includeResolved) { + return query.where(eq(threads.status, "active")).all(); + } + return query.all(); +} + +export function getThreadWithCandidates(db: Db = prodDb, threadId: number) { + const thread = db.select().from(threads) + .where(eq(threads.id, threadId)).get(); + if (!thread) return null; + + const candidates = db + .select({ + id: threadCandidates.id, + threadId: threadCandidates.threadId, + name: threadCandidates.name, + weightGrams: threadCandidates.weightGrams, + priceCents: threadCandidates.priceCents, + categoryId: threadCandidates.categoryId, + notes: threadCandidates.notes, + productUrl: threadCandidates.productUrl, + imageFilename: threadCandidates.imageFilename, + createdAt: threadCandidates.createdAt, + updatedAt: threadCandidates.updatedAt, + categoryName: categories.name, + categoryEmoji: categories.emoji, + }) + .from(threadCandidates) + .innerJoin(categories, eq(threadCandidates.categoryId, categories.id)) + .where(eq(threadCandidates.threadId, threadId)) + .all(); + + return { ...thread, candidates }; +} +``` + +### TanStack Query Hooks for Threads + +```typescript +// src/client/hooks/useThreads.ts +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api"; + +export function useThreads(includeResolved = false) { + return useQuery({ + queryKey: ["threads", { includeResolved }], + queryFn: () => apiGet(`/api/threads${includeResolved ? "?includeResolved=true" : ""}`), + }); +} + +export function useThread(threadId: number | null) { + return useQuery({ + queryKey: ["threads", threadId], + queryFn: () => apiGet(`/api/threads/${threadId}`), + enabled: threadId != null, + }); +} + +export function useResolveThread() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ threadId, candidateId }: { threadId: number; candidateId: number }) => + apiPost(`/api/threads/${threadId}/resolve`, { candidateId }), + onSuccess: () => { + // Invalidate ALL affected queries + queryClient.invalidateQueries({ queryKey: ["threads"] }); + queryClient.invalidateQueries({ queryKey: ["items"] }); + queryClient.invalidateQueries({ queryKey: ["totals"] }); + }, + }); +} +``` + +### Thread Routes Pattern (following items.ts) + +```typescript +// src/server/routes/threads.ts +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { createThreadSchema, updateThreadSchema, resolveThreadSchema, + createCandidateSchema, updateCandidateSchema } from "../../shared/schemas.ts"; + +type Env = { Variables: { db?: any } }; +const app = new Hono(); + +// Thread CRUD +app.get("/", (c) => { /* getAllThreads */ }); +app.post("/", zValidator("json", createThreadSchema), (c) => { /* createThread */ }); +app.get("/:id", (c) => { /* getThreadWithCandidates */ }); +app.put("/:id", zValidator("json", updateThreadSchema), (c) => { /* updateThread */ }); +app.delete("/:id", (c) => { /* deleteThread with image cleanup */ }); + +// Candidate CRUD (nested under thread) +app.post("/:id/candidates", zValidator("json", createCandidateSchema), (c) => { /* addCandidate */ }); +app.put("/:threadId/candidates/:candidateId", zValidator("json", updateCandidateSchema), (c) => { /* updateCandidate */ }); +app.delete("/:threadId/candidates/:candidateId", (c) => { /* removeCandidate */ }); + +// Resolution +app.post("/:id/resolve", zValidator("json", resolveThreadSchema), (c) => { /* resolveThread */ }); + +export { app as threadRoutes }; +``` + +### Test Helper Update + +```typescript +// Addition to tests/helpers/db.ts createTestDb() + +sqlite.run(` + CREATE TABLE threads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + resolved_candidate_id INTEGER, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) + ) +`); + +sqlite.run(` + CREATE TABLE thread_candidates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE, + name TEXT NOT NULL, + weight_grams REAL, + price_cents INTEGER, + category_id INTEGER NOT NULL REFERENCES categories(id), + notes TEXT, + product_url TEXT, + image_filename TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) + ) +`); +``` + +## State of the Art + +No new libraries or version changes since Phase 1. The entire stack is already installed and verified. + +| Phase 1 Pattern | Phase 2 Extension | Notes | +|-----------------|-------------------|-------| +| items table | threads + thread_candidates tables | Candidates mirror items schema | +| item.service.ts | thread.service.ts | Same DI pattern, adds transaction for resolution | +| /api/items routes | /api/threads routes | Nested candidate routes under thread | +| useItems hooks | useThreads + useCandidates hooks | Same TanStack Query patterns | +| ItemCard component | ThreadCard + CandidateCard | Same visual style with pill/chip tags | +| ItemForm component | CandidateForm | Same fields, different API endpoints | +| uiStore panel state | Extended with thread panel/dialog state | Same Zustand pattern | + +## Open Questions + +1. **Image handling on resolution** + - What we know: Candidate imageFilename is copied to the new item + - What's unclear: Should the file be duplicated on disk to prevent orphaned references? + - Recommendation: Copy the file to a new filename during resolution. This prevents the edge case where thread deletion removes an image still used by a collection item. The copy operation is cheap for small image files. + +2. **Thread deletion** + - What we know: Resolved threads are archived, not deleted. Active threads can be deleted. + - What's unclear: Should users be able to delete resolved/archived threads? + - Recommendation: Allow deletion of both active and archived threads with a confirmation dialog. Image cleanup required in both cases. + +3. **Category on thread cards** + - What we know: Thread cards show name, candidate count, date, price range + - What's unclear: Thread itself has no category -- it's a container for candidates + - Recommendation: Threads don't need a category. The pill tags on thread cards show: candidate count, date created, price range (min-max of candidates). + +## Validation Architecture + +### Test Framework +| Property | Value | +|----------|-------| +| Framework | Bun test runner (built-in, Jest-compatible API) | +| Config file | None needed (Bun detects test files automatically) | +| Quick run command | `bun test --bail` | +| Full suite command | `bun test` | + +### Phase Requirements -> Test Map +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| THRD-01 | Create thread with name, list threads | unit | `bun test tests/services/thread.service.test.ts -t "create"` | No - Wave 0 | +| THRD-01 | POST /api/threads validates input | integration | `bun test tests/routes/threads.test.ts -t "create"` | No - Wave 0 | +| THRD-02 | Add candidate to thread with all fields | unit | `bun test tests/services/thread.service.test.ts -t "candidate"` | No - Wave 0 | +| THRD-02 | POST /api/threads/:id/candidates validates | integration | `bun test tests/routes/threads.test.ts -t "candidate"` | No - Wave 0 | +| THRD-03 | Update and delete candidates | unit | `bun test tests/services/thread.service.test.ts -t "update\|delete"` | No - Wave 0 | +| THRD-04 | Resolve thread creates item and archives | unit | `bun test tests/services/thread.service.test.ts -t "resolve"` | No - Wave 0 | +| THRD-04 | Resolve validates candidate belongs to thread | unit | `bun test tests/services/thread.service.test.ts -t "resolve"` | No - Wave 0 | +| THRD-04 | POST /api/threads/:id/resolve end-to-end | integration | `bun test tests/routes/threads.test.ts -t "resolve"` | No - Wave 0 | +| THRD-04 | Resolved thread excluded from active list | unit | `bun test tests/services/thread.service.test.ts -t "list"` | No - Wave 0 | + +### Sampling Rate +- **Per task commit:** `bun test --bail` +- **Per wave merge:** `bun test` +- **Phase gate:** Full suite green before `/gsd:verify-work` + +### Wave 0 Gaps +- [ ] `tests/services/thread.service.test.ts` -- covers THRD-01, THRD-02, THRD-03, THRD-04 +- [ ] `tests/routes/threads.test.ts` -- integration tests for thread API endpoints +- [ ] `tests/helpers/db.ts` -- MODIFY: add threads + thread_candidates table creation + +## Sources + +### Primary (HIGH confidence) +- Existing codebase: `src/db/schema.ts`, `src/server/services/item.service.ts`, `src/server/routes/items.ts` -- established patterns to replicate +- Existing codebase: `tests/helpers/db.ts`, `tests/services/item.service.test.ts` -- test infrastructure and patterns +- Existing codebase: `src/client/hooks/useItems.ts`, `src/client/stores/uiStore.ts` -- client-side patterns +- Phase 1 research: `.planning/phases/01-foundation-and-collection/01-RESEARCH.md` -- stack decisions and verified versions +- Drizzle ORM transactions: `db.transaction()` -- verified in category.service.ts (deleteCategory uses it) + +### Secondary (MEDIUM confidence) +- TanStack Router `validateSearch` for search param validation -- documented in TanStack Router docs, used for tab routing + +### Tertiary (LOW confidence) +- Image file copy on resolution -- needs implementation validation (best practice, but filesystem operations in Bun may have edge cases) + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH -- no new libraries, all from Phase 1 +- Architecture: HIGH -- direct extension of proven Phase 1 patterns, schema/service/route/hook layers +- Pitfalls: HIGH -- drawn from analysis of resolution flow edge cases and Phase 1 experience +- Database schema: HIGH -- mirrors items table (locked decision), transaction pattern established in category.service.ts + +**Research date:** 2026-03-15 +**Valid until:** 2026-04-15 (stable ecosystem, no fast-moving dependencies)