Archive roadmap, requirements, and phase directories to milestones/. Evolve PROJECT.md with validated requirements and key decisions. Reorganize ROADMAP.md with milestone grouping. Delete REQUIREMENTS.md (fresh for next milestone).
607 lines
29 KiB
Markdown
607 lines
29 KiB
Markdown
# 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>
|
|
|
|
## 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)
|
|
|
|
</user_constraints>
|
|
|
|
<phase_requirements>
|
|
|
|
## 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 |
|
|
|
|
</phase_requirements>
|
|
|
|
## 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 (
|
|
<div>
|
|
<TabSwitcher
|
|
active={tab}
|
|
onChange={(t) => navigate({ search: { tab: t } })}
|
|
/>
|
|
{tab === "gear" ? <CollectionView /> : <PlanningView />}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**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<number>`(
|
|
SELECT COUNT(*) FROM thread_candidates
|
|
WHERE thread_id = ${threads.id}
|
|
)`,
|
|
minPriceCents: sql<number | null>`(
|
|
SELECT MIN(price_cents) FROM thread_candidates
|
|
WHERE thread_id = ${threads.id}
|
|
)`,
|
|
maxPriceCents: sql<number | null>`(
|
|
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<Env>();
|
|
|
|
// 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)
|