Files
Jean-Luc Makiola 261c1f9d02 chore: complete v1.0 MVP milestone
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).
2026-03-15 15:49:45 +01:00

29 KiB

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.

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

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

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

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

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

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

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

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