# Phase 8: Search, Filter, and Candidate Status - Research

**Researched:** 2026-03-16
**Domain:** Client-side filtering, searchable dropdown components, schema migration, status badges
**Confidence:** HIGH

## Summary

Phase 8 adds three capabilities to GearBox: (1) a search and category filter toolbar on the gear tab with result counts, (2) an icon-aware searchable category filter dropdown shared between gear and planning tabs, and (3) candidate status tracking (researching/ordered/arrived) with clickable status badges. The work spans all layers: schema migration (adding `status` column to `thread_candidates`), service/route updates (CRUD for status field), Zod schema updates, and several new client components.

The codebase is well-structured for these additions. Client-side filtering is straightforward since `useItems()` already returns all items with category info. The `CategoryPicker` component provides a reference pattern for the searchable dropdown, though the new `CategoryFilterDropdown` is simpler (no creation flow). The candidate status feature requires a schema migration, but Drizzle Kit and the existing migration infrastructure handle this cleanly.

**Primary recommendation:** Build in two waves -- (1) backend schema migration + candidate status (smaller, foundational), then (2) search/filter toolbar and shared category dropdown (larger, UI-focused). Both waves are pure client-side filtering with minimal server changes.

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions
- Sticky toolbar above the item grid on the gear tab, stays visible on scroll
- Search input + category dropdown side by side in the toolbar
- Client-side filtering on every keystroke (no debounce needed for <1000 items)
- Search matches item names only (not category names) -- category filtering is the dropdown's job
- When any filter is active, items display as a flat grid (no category group headers)
- Filters reset when switching between gear/planning/setups tabs
- Three statuses: researching (default), ordered, arrived
- Status badge appears in the existing pill row alongside weight/price/category pills
- Badge shows icon + text label (e.g., magnifying glass + "researching", truck + "ordered", check + "arrived")
- Muted/neutral color scheme for status badges -- gray tones, not semantic colors
- Click the status badge to open a small popup menu showing all three status options
- New candidates default to "researching" status
- Requires `status` column on `thread_candidates` table (schema migration)
- "Showing X of Y items" count displayed when filters are active
- No combined "clear all" button -- user clears search text and resets category dropdown individually
- "No items match your search" simple text message for empty filter results
- Shared `CategoryFilterDropdown` component used on both gear tab and planning tab
- Separate from existing `CategoryPicker` component
- "All categories" as the first option -- selecting it clears the category filter
- Searchable dropdown with search input inside
- Trigger button shows selected category's Lucide icon + name when selected

### Claude's Discretion
- Exact toolbar styling (padding, borders, background)
- Filter result count placement (in toolbar or above grid)
- Status popup menu implementation details
- Specific gray tone values for status badges
- Keyboard accessibility patterns for the dropdown and status menu
- Icon choices for status badges (magnifying glass, truck, check are suggestions)

### Deferred Ideas (OUT OF SCOPE)
None
</user_constraints>

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|-----------------|
| SRCH-01 | User can search items by name with instant filtering as they type | Client-side `useState` + `.filter()` on `useItems()` data. Pattern documented in Architecture section |
| SRCH-02 | User can filter collection items by category via dropdown | New `CategoryFilterDropdown` component using `useCategories()` data. Pattern from existing `CategoryPicker` |
| SRCH-03 | User can combine text search with category filter simultaneously | Chain `.filter()` calls -- search text AND category ID. Both stored as `useState` in `CollectionView` |
| SRCH-04 | User can see result count when filters are active | Computed from `filteredItems.length` vs `items.length`. Conditional rendering when filters active |
| SRCH-05 | User can clear all active filters with one action | Per CONTEXT.md: no combined button. User clears search text and resets dropdown individually. Both inputs have clear affordances |
| PLAN-01 | Planning category filter dropdown shows Lucide icons alongside names | Replace existing `<select>` in `PlanningView` with shared `CategoryFilterDropdown` |
| CAND-01 | Each candidate displays a status badge (researching, ordered, or arrived) | Add `status` prop to `CandidateCard`, render as pill in existing flex row |
| CAND-02 | User can change a candidate's status via click interaction | Status badge click opens popup menu. Uses `useUpdateCandidate` mutation with `status` field |
| CAND-03 | New candidates default to "researching" status | Schema default + Drizzle `.default("researching")`. Service layer already handles defaults via `?? null` pattern |
</phase_requirements>

## Standard Stack

### Core (Already in Project)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| React | 19 | UI framework | Already installed, all components use it |
| TanStack React Query | - | Server state | Already used for `useItems`, `useCategories`, `useThreads` |
| Zustand | - | UI state (panels/dialogs only) | Already used in `uiStore.ts` |
| Drizzle ORM | - | Database schema + queries | Already used for all DB operations |
| Drizzle Kit | - | Schema migration generation | Already configured in `drizzle.config.ts` |
| Zod | - | Request validation | Already used in `schemas.ts` and route validators |
| Hono | - | Server framework | Already used for all API routes |
| lucide-react | - | Icons | Already used via `LucideIcon` component for all icons |
| Tailwind CSS | v4 | Styling | Already used throughout |

### No New Dependencies Required

This phase uses only existing libraries. No new packages needed.

## Architecture Patterns

### Recommended Project Structure (Changes Only)
```
src/
  client/
    components/
      CategoryFilterDropdown.tsx    # NEW - shared searchable category filter
      StatusBadge.tsx               # NEW - clickable status badge with popup menu
      CandidateCard.tsx             # MODIFIED - add status prop and badge
    routes/
      collection/
        index.tsx                   # MODIFIED - add search/filter toolbar to CollectionView
                                    #           - replace <select> in PlanningView
  server/
    services/
      thread.service.ts             # MODIFIED - handle status field in create/update candidate
    routes/
      threads.ts                    # NO CHANGES - already delegates to service
  shared/
    schemas.ts                      # MODIFIED - add status to candidate schemas
    types.ts                        # NO CHANGES - types auto-infer from schemas
  db/
    schema.ts                       # MODIFIED - add status column to threadCandidates
tests/
  helpers/
    db.ts                           # MODIFIED - add status column to thread_candidates CREATE TABLE
  services/
    thread.service.test.ts          # MODIFIED - add tests for status field
```

### Pattern 1: Client-Side Filtering with useState
**What:** Filter items in-memory using React state, no server round-trips
**When to use:** Small datasets (<1000 items), instant feedback needed
**Example:**
```typescript
// In CollectionView
function CollectionView() {
  const [searchText, setSearchText] = useState("");
  const [categoryFilter, setCategoryFilter] = useState<number | null>(null);
  const { data: items } = useItems();

  const filteredItems = useMemo(() => {
    if (!items) return [];
    return items.filter((item) => {
      const matchesSearch = searchText === "" ||
        item.name.toLowerCase().includes(searchText.toLowerCase());
      const matchesCategory = categoryFilter === null ||
        item.categoryId === categoryFilter;
      return matchesSearch && matchesCategory;
    });
  }, [items, searchText, categoryFilter]);

  const hasActiveFilters = searchText !== "" || categoryFilter !== null;
  // ...
}
```

### Pattern 2: Searchable Dropdown with Click-Outside Dismiss
**What:** Dropdown with internal search input, opens on click, closes on click-outside or Escape
**When to use:** Category filter dropdowns where a native `<select>` is insufficient (need icons, search)
**Example:**
```typescript
// Reference: existing CategoryPicker pattern (containerRef + useEffect for mousedown)
function CategoryFilterDropdown({ value, onChange, categories }) {
  const [isOpen, setIsOpen] = useState(false);
  const [search, setSearch] = useState("");
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    function handleClickOutside(e: MouseEvent) {
      if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
        setIsOpen(false);
        setSearch("");
      }
    }
    document.addEventListener("mousedown", handleClickOutside);
    return () => document.removeEventListener("mousedown", handleClickOutside);
  }, []);
  // ... trigger button + dropdown list with LucideIcon per option
}
```

### Pattern 3: Status Badge with Popup Menu
**What:** Clickable pill badge that opens a small menu to change status
**When to use:** Inline status changes without opening a modal/panel
**Example:**
```typescript
// StatusBadge - renders in CandidateCard's pill row
function StatusBadge({ status, onStatusChange }: {
  status: "researching" | "ordered" | "arrived";
  onStatusChange: (status: string) => void;
}) {
  const [menuOpen, setMenuOpen] = useState(false);
  const containerRef = useRef<HTMLDivElement>(null);
  // Click-outside dismiss pattern (same as CategoryPicker)
  // Renders: pill button + absolute-positioned menu with 3 options
}
```

### Pattern 4: Schema Migration with Default Value
**What:** Add column with default to existing table using Drizzle Kit
**When to use:** Adding new fields that need backward compatibility with existing rows
**Example:**
```typescript
// In src/db/schema.ts -- add to threadCandidates table definition:
status: text("status").notNull().default("researching"),

// Then run: bun run db:generate && bun run db:push
// Drizzle Kit will generate: ALTER TABLE thread_candidates ADD COLUMN status TEXT NOT NULL DEFAULT 'researching'
```

### Pattern 5: Flat Grid vs Category-Grouped Grid
**What:** Conditionally render items as flat grid or category-grouped sections
**When to use:** When filters are active, category grouping loses meaning
**Example:**
```typescript
// When filters active: flat grid of filteredItems
// When no filters: existing category-grouped Map pattern (already in CollectionView)
const hasActiveFilters = searchText !== "" || categoryFilter !== null;

return hasActiveFilters ? (
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
    {filteredItems.map((item) => <ItemCard key={item.id} ... />)}
  </div>
) : (
  // Existing grouped rendering with CategoryHeader
  <>
    {Array.from(groupedItems.entries()).map(([categoryId, { items, ... }]) => (
      // ... existing CategoryHeader + grid pattern
    ))}
  </>
);
```

### Anti-Patterns to Avoid
- **Server-side filtering for this use case:** Out of scope per REQUIREMENTS.md ("Premature for single-user app with <1000 items"). All filtering is client-side.
- **Zustand for filter state:** Per codebase convention, filter/tab state uses `useState` in route components, not Zustand. Zustand is only for panel/dialog state.
- **Debouncing search input:** Per CONTEXT.md, no debounce needed for <1000 items. React is fast enough for synchronous filtering.
- **Modifying CategoryPicker:** The new dropdown is separate from `CategoryPicker`. CategoryPicker is a form combobox for category selection/creation. Do not conflate them.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Click-outside detection | Custom event system | `useEffect` + `mousedown` listener on `document` (existing pattern from `CategoryPicker`) | Pattern already proven in codebase, handles edge cases |
| Dynamic icon rendering | SVG string lookup | `LucideIcon` component from `src/client/lib/iconData.tsx` | Already handles kebab-case to PascalCase conversion, fallback to Package icon |
| Schema migrations | Manual SQL | `bun run db:generate` + `bun run db:push` (Drizzle Kit) | Generates correct ALTER TABLE, manages migration journal |
| Popup menu positioning | Complex position calculation | CSS `position: absolute` + `right-0` on container with `position: relative` | Simple case -- badge is in a flex row, menu drops below. No viewport collision for this layout |

## Common Pitfalls

### Pitfall 1: Forgetting to Update Test Helper DB Schema
**What goes wrong:** Adding `status` column to `src/db/schema.ts` but not to `tests/helpers/db.ts` CREATE TABLE statement causes all thread service tests to fail.
**Why it happens:** The test helper creates in-memory SQLite tables manually, not via Drizzle migrations.
**How to avoid:** Always update both `src/db/schema.ts` AND `tests/helpers/db.ts` thread_candidates CREATE TABLE in the same commit.
**Warning signs:** Tests that worked before now fail with "table thread_candidates has no column named status".

### Pitfall 2: Filter State Not Resetting on Tab Switch
**What goes wrong:** User searches on gear tab, switches to planning, comes back -- old search text still showing stale filtered results.
**Why it happens:** useState persists while the component is mounted. Tab switching in `CollectionPage` conditionally renders views but `CollectionView` may stay mounted if React reuses the component.
**How to avoid:** Use a `key` prop tied to the tab value on the view components, or explicitly reset filter state in a `useEffect` keyed on tab changes. The simplest approach: since `CollectionView` is conditionally rendered (unmounted when tab !== "gear"), useState will naturally reset. Verify this is the case.
**Warning signs:** Filters persisting when switching tabs.

### Pitfall 3: Status Badge Click Propagating to Card Actions
**What goes wrong:** Clicking the status badge also triggers the card's edit panel or other click handlers.
**Why it happens:** Event bubbling -- `CandidateCard` has click handlers on parent elements.
**How to avoid:** Call `e.stopPropagation()` on the status badge click handler. The existing code already does this for the external link button.
**Warning signs:** Clicking status badge opens the edit panel instead of the status menu.

### Pitfall 4: Candidate Status Not Included in API Responses
**What goes wrong:** Status column is added to schema but `getThreadWithCandidates` doesn't select it, so frontend never receives it.
**Why it happens:** The service uses explicit `select()` clauses, not `select(*)`. New columns must be explicitly added.
**How to avoid:** Add `status: threadCandidates.status` to the select object in `getThreadWithCandidates`.
**Warning signs:** Status badge always shows "researching" even after changing it.

### Pitfall 5: Zod Schema Missing Status in updateCandidateSchema
**What goes wrong:** PUT request to update candidate status gets rejected by Zod validation.
**Why it happens:** `updateCandidateSchema = createCandidateSchema.partial()` -- if `createCandidateSchema` doesn't include status, neither does update.
**How to avoid:** Add `status` to `updateCandidateSchema` (and optionally `createCandidateSchema`). Use `z.enum(["researching", "ordered", "arrived"])`.
**Warning signs:** 400 errors when trying to change status via the badge.

### Pitfall 6: Sticky Toolbar Covering Content
**What goes wrong:** The sticky search/filter toolbar overlaps the first row of items when scrolled.
**Why it happens:** `position: sticky` without adequate spacing pushes content under the toolbar.
**How to avoid:** Ensure the grid content below the toolbar has no negative margin or overlapping. The toolbar sits in normal flow and sticks on scroll -- padding/margin on the toolbar itself handles spacing.
**Warning signs:** First item card partially hidden behind the toolbar when scrolling.

## Code Examples

### Schema Migration: Add Status Column
```typescript
// src/db/schema.ts -- threadCandidates table
export const threadCandidates = sqliteTable("thread_candidates", {
  // ... existing columns ...
  status: text("status").notNull().default("researching"),
});
```

### Zod Schema Update
```typescript
// src/shared/schemas.ts
export const candidateStatusSchema = z.enum(["researching", "ordered", "arrived"]);

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(),
  status: candidateStatusSchema.optional(), // optional on create, defaults to "researching"
});

export const updateCandidateSchema = createCandidateSchema.partial();
// This automatically includes status as optional
```

### Service Update: Status in getThreadWithCandidates
```typescript
// src/server/services/thread.service.ts -- in getThreadWithCandidates
const candidateList = 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,
    status: threadCandidates.status,  // ADD THIS
    createdAt: threadCandidates.createdAt,
    updatedAt: threadCandidates.updatedAt,
    categoryName: categories.name,
    categoryIcon: categories.icon,
  })
  .from(threadCandidates)
  .innerJoin(categories, eq(threadCandidates.categoryId, categories.id))
  .where(eq(threadCandidates.threadId, threadId))
  .all();
```

### Test Helper Update
```sql
-- tests/helpers/db.ts -- thread_candidates CREATE TABLE
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,
  status TEXT NOT NULL DEFAULT 'researching',
  created_at INTEGER NOT NULL DEFAULT (unixepoch()),
  updated_at INTEGER NOT NULL DEFAULT (unixepoch())
)
```

### Client Hook Update: CandidateWithCategory Type
```typescript
// src/client/hooks/useThreads.ts -- add status to CandidateWithCategory
interface CandidateWithCategory {
  id: number;
  threadId: number;
  name: string;
  weightGrams: number | null;
  priceCents: number | null;
  categoryId: number;
  notes: string | null;
  productUrl: string | null;
  imageFilename: string | null;
  status: "researching" | "ordered" | "arrived";  // ADD THIS
  createdAt: string;
  updatedAt: string;
  categoryName: string;
  categoryIcon: string;
}
```

### Lucide Icon Names for Status Badges
```typescript
// Available in lucide-react (verified via iconData.tsx icon groups)
const STATUS_CONFIG = {
  researching: { icon: "search", label: "Researching" },
  ordered: { icon: "truck", label: "Ordered" },
  arrived: { icon: "check", label: "Arrived" },
} as const;
// Note: "search" maps to lucide's Search icon (magnifying glass)
// "truck" maps to Truck icon
// "check" maps to Check icon
// All are valid lucide-react icon names and work with the LucideIcon component
```

### Sticky Toolbar Pattern
```typescript
// Toolbar sticks to top on scroll
<div className="sticky top-0 z-10 bg-white/95 backdrop-blur-sm border-b border-gray-100 -mx-4 px-4 py-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
  <div className="flex gap-3 items-center">
    <input
      type="text"
      placeholder="Search items..."
      value={searchText}
      onChange={(e) => setSearchText(e.target.value)}
      className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm ..."
    />
    <CategoryFilterDropdown
      value={categoryFilter}
      onChange={setCategoryFilter}
    />
  </div>
</div>
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Native `<select>` for category filter | Searchable dropdown with icons | This phase | Planning view's `<select>` replaced with `CategoryFilterDropdown` |
| No candidate status tracking | `status` column with badge UI | This phase | Candidates now track purchase progress |
| Category-grouped items only | Conditional flat grid when filtering | This phase | Better UX when searching/filtering |

## Open Questions

1. **Sticky toolbar `top` offset**
   - What we know: The toolbar should be `sticky top-0` but needs to account for any fixed header/navbar if one exists.
   - What's unclear: Whether there's a fixed navbar above the collection page that would require a `top-[Npx]` offset instead of `top-0`.
   - Recommendation: Start with `top-0`. If there's a fixed navbar, adjust the top value to match its height. The current layout appears to not have a fixed navbar based on the route structure.

2. **useCandidates hook status mutation**
   - What we know: `useUpdateCandidate` already exists and can be used for status changes via `apiPut`.
   - What's unclear: Whether a dedicated `useUpdateCandidateStatus` hook is cleaner than reusing the general `useUpdateCandidate`.
   - Recommendation: Reuse `useUpdateCandidate` -- it already accepts partial updates. Adding a dedicated hook would be unnecessary abstraction.

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | Bun test runner (built-in) |
| Config file | None (uses bun defaults) |
| Quick run command | `bun test tests/services/thread.service.test.ts` |
| Full suite command | `bun test` |

### Phase Requirements to Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| SRCH-01 | Search items by name with instant filtering | manual-only | N/A -- client-side `useState` + `filter()`, no testable service | N/A |
| SRCH-02 | Filter by category via dropdown | manual-only | N/A -- client-side component logic | N/A |
| SRCH-03 | Combine text search with category filter | manual-only | N/A -- client-side filtering logic | N/A |
| SRCH-04 | Show result count when filters active | manual-only | N/A -- computed in render | N/A |
| SRCH-05 | Clear filters individually | manual-only | N/A -- UI interaction | N/A |
| PLAN-01 | Category dropdown shows icons | manual-only | N/A -- component rendering | N/A |
| CAND-01 | Candidate displays status badge | unit | `bun test tests/services/thread.service.test.ts` | Needs update |
| CAND-02 | User can change candidate status | unit | `bun test tests/services/thread.service.test.ts` | Needs update |
| CAND-03 | New candidates default to "researching" | unit | `bun test tests/services/thread.service.test.ts` | Needs update |

### Sampling Rate
- **Per task commit:** `bun test tests/services/thread.service.test.ts`
- **Per wave merge:** `bun test`
- **Phase gate:** Full suite green before `/gsd:verify-work`

### Wave 0 Gaps
- [ ] `tests/helpers/db.ts` -- add `status TEXT NOT NULL DEFAULT 'researching'` to thread_candidates CREATE TABLE
- [ ] `tests/services/thread.service.test.ts` -- add tests for: (1) createCandidate returns status "researching" by default, (2) updateCandidate can change status, (3) getThreadWithCandidates includes status field

## Sources

### Primary (HIGH confidence)
- **Codebase analysis** -- direct reading of all relevant source files:
  - `src/db/schema.ts` -- current threadCandidates table definition (no status column)
  - `src/client/routes/collection/index.tsx` -- CollectionView (where toolbar goes) and PlanningView (where `<select>` is replaced)
  - `src/client/components/CandidateCard.tsx` -- current pill row layout (where status badge goes)
  - `src/client/components/CategoryPicker.tsx` -- searchable dropdown reference pattern
  - `src/client/lib/iconData.tsx` -- LucideIcon component and available icon names
  - `src/server/services/thread.service.ts` -- candidate CRUD with explicit select fields
  - `src/shared/schemas.ts` -- Zod validation schemas for candidates
  - `src/client/hooks/useThreads.ts` -- CandidateWithCategory interface
  - `src/client/hooks/useCandidates.ts` -- mutation hooks for candidates
  - `tests/helpers/db.ts` -- test helper CREATE TABLE statements
  - `drizzle.config.ts` -- migration config
  - `drizzle/0001_rename_emoji_to_icon.sql` -- migration precedent

### Secondary (MEDIUM confidence)
- **Drizzle ORM** -- ALTER TABLE ADD COLUMN with DEFAULT for SQLite is well-documented and standard

### Tertiary (LOW confidence)
- None -- all findings are from direct codebase analysis

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH -- no new libraries, all existing
- Architecture: HIGH -- patterns derived from existing codebase conventions
- Pitfalls: HIGH -- identified from actual code reading (explicit selects, test helper, event bubbling)
- Schema migration: HIGH -- follows existing migration pattern (drizzle/0001_rename_emoji_to_icon.sql)

**Research date:** 2026-03-16
**Valid until:** 2026-04-16 (stable -- internal codebase patterns, no external dependency concerns)
