docs(phase-8): research phase domain
This commit is contained in:
@@ -0,0 +1,491 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user