From 7cd4b467d0cd4d2bcc2004149ff63e111cc88914 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 16 Mar 2026 13:03:20 +0100 Subject: [PATCH] docs(08): create phase plan --- .planning/ROADMAP.md | 8 +- .../08-01-PLAN.md | 240 ++++++++++++++ .../08-02-PLAN.md | 292 ++++++++++++++++++ 3 files changed, 536 insertions(+), 4 deletions(-) create mode 100644 .planning/phases/08-search-filter-and-candidate-status/08-01-PLAN.md create mode 100644 .planning/phases/08-search-filter-and-candidate-status/08-02-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 90cf0a4..a1cd4dd 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -60,11 +60,11 @@ Plans: 3. User can see how many items match active filters (e.g., "showing 12 of 47 items") and clear all filters with one action 4. Each candidate in a planning thread displays a status badge (researching, ordered, or arrived) that the user can change by clicking 5. New candidates automatically start with "researching" status -**Plans**: TBD +**Plans:** 2 plans Plans: -- [ ] 08-01: TBD -- [ ] 08-02: TBD +- [ ] 08-01-PLAN.md -- Candidate status vertical slice (schema migration, service, tests, StatusBadge UI) +- [ ] 08-02-PLAN.md -- Search/filter toolbar with CategoryFilterDropdown on gear and planning tabs ### Phase 9: Weight Classification and Visualization **Goal**: Users can classify gear by role and visualize weight distribution in setups @@ -94,5 +94,5 @@ Plans: | 5. Image Handling | v1.1 | 2/2 | Complete | 2026-03-15 | | 6. Category Icons | v1.1 | 3/3 | Complete | 2026-03-15 | | 7. Weight Unit Selection | v1.2 | 1/2 | In Progress | - | -| 8. Search, Filter, and Candidate Status | v1.2 | 0/? | Not started | - | +| 8. Search, Filter, and Candidate Status | v1.2 | 0/2 | Not started | - | | 9. Weight Classification and Visualization | v1.2 | 0/? | Not started | - | diff --git a/.planning/phases/08-search-filter-and-candidate-status/08-01-PLAN.md b/.planning/phases/08-search-filter-and-candidate-status/08-01-PLAN.md new file mode 100644 index 0000000..c906e12 --- /dev/null +++ b/.planning/phases/08-search-filter-and-candidate-status/08-01-PLAN.md @@ -0,0 +1,240 @@ +--- +phase: 08-search-filter-and-candidate-status +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - src/db/schema.ts + - src/shared/schemas.ts + - src/server/services/thread.service.ts + - src/client/hooks/useThreads.ts + - src/client/hooks/useCandidates.ts + - src/client/components/StatusBadge.tsx + - src/client/components/CandidateCard.tsx + - tests/helpers/db.ts + - tests/services/thread.service.test.ts +autonomous: true +requirements: [CAND-01, CAND-02, CAND-03] + +must_haves: + truths: + - "Each candidate displays a status badge showing one of three statuses: researching, ordered, or arrived" + - "User can click a status badge to open a popup menu and change the candidate's status to any of the three options" + - "New candidates automatically have status 'researching' without the user needing to set it" + artifacts: + - path: "src/db/schema.ts" + provides: "status column on threadCandidates table" + contains: "status: text(\"status\").notNull().default(\"researching\")" + - path: "src/shared/schemas.ts" + provides: "candidateStatusSchema Zod enum" + exports: ["candidateStatusSchema"] + - path: "src/server/services/thread.service.ts" + provides: "status field in candidate CRUD operations" + contains: "status: threadCandidates.status" + - path: "src/client/components/StatusBadge.tsx" + provides: "Clickable status badge with popup menu" + exports: ["StatusBadge"] + - path: "src/client/components/CandidateCard.tsx" + provides: "CandidateCard renders StatusBadge in pill row" + contains: "StatusBadge" + - path: "tests/helpers/db.ts" + provides: "status column in test helper CREATE TABLE" + contains: "status TEXT NOT NULL DEFAULT 'researching'" + key_links: + - from: "src/client/components/StatusBadge.tsx" + to: "/api/threads/:id/candidates/:candidateId" + via: "useUpdateCandidate mutation" + pattern: "onStatusChange" + - from: "src/server/services/thread.service.ts" + to: "src/db/schema.ts" + via: "threadCandidates.status in select and update" + pattern: "threadCandidates\\.status" + - from: "src/client/components/CandidateCard.tsx" + to: "src/client/components/StatusBadge.tsx" + via: "StatusBadge component in pill row" + pattern: " +Add candidate status tracking (researching/ordered/arrived) as a full vertical slice: schema migration, service/Zod updates, tests, and clickable status badge UI on CandidateCard. + +Purpose: Let users track purchase progress for candidates they are evaluating in planning threads. +Output: Working status badge on each candidate card with popup menu to change status. + + + +@/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md +@/home/jlmak/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/08-search-filter-and-candidate-status/08-CONTEXT.md +@.planning/phases/08-search-filter-and-candidate-status/08-RESEARCH.md + +@src/db/schema.ts +@src/shared/schemas.ts +@src/shared/types.ts +@src/server/services/thread.service.ts +@src/client/hooks/useThreads.ts +@src/client/hooks/useCandidates.ts +@src/client/components/CandidateCard.tsx +@tests/helpers/db.ts +@tests/services/thread.service.test.ts + + + + +From src/shared/types.ts: +```typescript +export type CreateCandidate = z.infer; +export type UpdateCandidate = z.infer; +export type ThreadCandidate = typeof threadCandidates.$inferSelect; +``` + +From src/client/hooks/useCandidates.ts: +```typescript +export function useUpdateCandidate(threadId: number) { + // mutationFn: ({ candidateId, ...data }) => apiPut(...) + // Already accepts partial updates. Use for status changes. +} +``` + +From src/client/hooks/useThreads.ts: +```typescript +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; + createdAt: string; updatedAt: string; + categoryName: string; categoryIcon: string; + // status field NOT YET present -- Task 1 adds it +} +``` + +From src/client/components/CandidateCard.tsx: +```typescript +interface CandidateCardProps { + id: number; name: string; weightGrams: number | null; + priceCents: number | null; categoryName: string; + categoryIcon: string; imageFilename: string | null; + productUrl?: string | null; threadId: number; isActive: boolean; + // status prop NOT YET present -- Task 2 adds it +} +``` + +From src/client/lib/iconData.tsx: +```typescript +export function LucideIcon({ name, size, className }: { + name: string; size?: number; className?: string; +}): JSX.Element; +// Valid icon names for status: "search", "truck", "check" +``` + + + + + + + Task 1: Add status column and update backend + tests + src/db/schema.ts, src/shared/schemas.ts, src/server/services/thread.service.ts, src/client/hooks/useThreads.ts, src/client/hooks/useCandidates.ts, tests/helpers/db.ts, tests/services/thread.service.test.ts + + - Test: createCandidate without status returns a candidate with status "researching" + - Test: createCandidate with status "ordered" returns a candidate with status "ordered" + - Test: updateCandidate can change status from "researching" to "ordered" + - Test: updateCandidate can change status from "ordered" to "arrived" + - Test: getThreadWithCandidates includes status field on each candidate + + + 1. **Schema migration** -- Add status column to `threadCandidates` in `src/db/schema.ts`: + ```typescript + status: text("status").notNull().default("researching"), + ``` + Then run `bun run db:generate && bun run db:push` to apply. + + 2. **Zod schemas** -- In `src/shared/schemas.ts`, add: + ```typescript + export const candidateStatusSchema = z.enum(["researching", "ordered", "arrived"]); + ``` + Add `status: candidateStatusSchema.optional()` to `createCandidateSchema`. Since `updateCandidateSchema = createCandidateSchema.partial()`, it automatically includes status as optional. + + 3. **Service updates** -- In `src/server/services/thread.service.ts`: + - In `getThreadWithCandidates`, add `status: threadCandidates.status` to the select object (between `imageFilename` and `createdAt`). + - In `createCandidate`, add `status: data.status ?? "researching"` to the values object. + - In `updateCandidate`, add `status` to the data type: `status: "researching" | "ordered" | "arrived"`. + + 4. **Client type updates** -- In `src/client/hooks/useThreads.ts`, add `status: "researching" | "ordered" | "arrived"` to `CandidateWithCategory` interface. In `src/client/hooks/useCandidates.ts`, add `status?: "researching" | "ordered" | "arrived"` to `CandidateResponse` interface. + + 5. **Test helper** -- In `tests/helpers/db.ts`, add `status TEXT NOT NULL DEFAULT 'researching'` to the `thread_candidates` CREATE TABLE statement (after `image_filename TEXT` line). + + 6. **Service tests** -- In `tests/services/thread.service.test.ts`, add a describe block "candidate status" with the tests from the behavior section above. + + + cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/services/thread.service.test.ts + + Status column exists in schema, migration applied, all CRUD operations handle status field, all tests pass including new status tests. + + + + Task 2: Create StatusBadge component and wire into CandidateCard + src/client/components/StatusBadge.tsx, src/client/components/CandidateCard.tsx + + 1. **Create `src/client/components/StatusBadge.tsx`** -- A clickable pill badge with popup menu: + - Props: `status: "researching" | "ordered" | "arrived"`, `onStatusChange: (status: "researching" | "ordered" | "arrived") => void` + - Status config map: + ```typescript + const STATUS_CONFIG = { + researching: { icon: "search", label: "Researching" }, + ordered: { icon: "truck", label: "Ordered" }, + arrived: { icon: "check", label: "Arrived" }, + } as const; + ``` + - Render as a pill button (muted gray tones per user decision -- NOT semantic colors): + - Use `bg-gray-100 text-gray-600` styling, similar neutral tone to the category pill + - Show `LucideIcon` (size 14) + text label + - On click: call `e.stopPropagation()` (prevent card click propagation per pitfall #3), toggle popup menu open/closed + - Popup menu: `position: absolute` below the badge, `right-0`, with 3 options (each showing icon + label). Use a `containerRef` + `useEffect` mousedown listener for click-outside dismiss (same pattern as `CategoryPicker`). Pressing Escape also closes the menu. + - When an option is clicked: call `onStatusChange(selectedStatus)`, close the menu. + - Show a subtle checkmark or different background on the currently active status in the menu. + + 2. **Update `src/client/components/CandidateCard.tsx`**: + - Add `status: "researching" | "ordered" | "arrived"` and `onStatusChange: (status: "researching" | "ordered" | "arrived") => void` to `CandidateCardProps`. + - Import `StatusBadge` from `./StatusBadge`. + - Add `` to the pill row (the `flex flex-wrap gap-1.5 mb-3` div), after the category pill. + + 3. **Update thread detail page caller** -- Find where `CandidateCard` is rendered (in the thread detail route). Add the `status` and `onStatusChange` props. For `onStatusChange`, use the existing `useUpdateCandidate` hook: `updateCandidate.mutate({ candidateId: candidate.id, status: newStatus })`. + + + cd /home/jlmak/Projects/jlmak/GearBox && bun run lint && bun test + + Each candidate card shows a gray status badge (icon + label) in the pill row. Clicking the badge opens a popup menu with all three status options. Selecting a status updates it via the API and the badge reflects the new status. New candidates show "Researching" by default. + + + + + +1. `bun test` -- all existing and new tests pass +2. `bun run lint` -- no lint errors +3. Start dev server (`bun run dev:server` + `bun run dev:client`), navigate to a thread detail page, verify: + - Each candidate shows a gray "Researching" badge in the pill row + - Clicking the badge opens a popup menu with Researching, Ordered, Arrived options + - Selecting a different status updates the badge immediately + - Refreshing the page shows the persisted status + + + +- Status column exists on thread_candidates table with default "researching" +- All candidate CRUD operations handle the status field +- StatusBadge component renders in CandidateCard pill row with muted gray styling +- Clicking badge opens popup menu, selecting an option changes status via API +- New candidates show "researching" status by default +- All tests pass including 5 new status-specific tests + + + +After completion, create `.planning/phases/08-search-filter-and-candidate-status/08-01-SUMMARY.md` + diff --git a/.planning/phases/08-search-filter-and-candidate-status/08-02-PLAN.md b/.planning/phases/08-search-filter-and-candidate-status/08-02-PLAN.md new file mode 100644 index 0000000..f6cbac9 --- /dev/null +++ b/.planning/phases/08-search-filter-and-candidate-status/08-02-PLAN.md @@ -0,0 +1,292 @@ +--- +phase: 08-search-filter-and-candidate-status +plan: 02 +type: execute +wave: 1 +depends_on: [] +files_modified: + - src/client/components/CategoryFilterDropdown.tsx + - src/client/routes/collection/index.tsx +autonomous: true +requirements: [SRCH-01, SRCH-02, SRCH-03, SRCH-04, SRCH-05, PLAN-01] + +must_haves: + truths: + - "User can type in a search field on the gear tab and see items filtered instantly by name as they type" + - "User can select a category from a searchable dropdown (with Lucide icons) to filter items on both gear and planning tabs" + - "User can combine text search with category filter to narrow results" + - "User sees 'Showing X of Y items' when filters are active on the gear tab" + - "User clears search text and resets category dropdown individually (no combined clear button)" + - "When filters are active, items display as a flat grid without category group headers" + - "Empty filter results show 'No items match your search' message" + - "Planning tab category filter shows Lucide icons alongside category names" + artifacts: + - path: "src/client/components/CategoryFilterDropdown.tsx" + provides: "Shared searchable category filter dropdown with Lucide icons" + exports: ["CategoryFilterDropdown"] + min_lines: 60 + - path: "src/client/routes/collection/index.tsx" + provides: "Search/filter toolbar in CollectionView, CategoryFilterDropdown in PlanningView" + contains: "CategoryFilterDropdown" + key_links: + - from: "src/client/components/CategoryFilterDropdown.tsx" + to: "src/client/hooks/useCategories.ts" + via: "categories prop passed from parent (useCategories data)" + pattern: "categories" + - from: "src/client/routes/collection/index.tsx (CollectionView)" + to: "src/client/components/CategoryFilterDropdown.tsx" + via: "CategoryFilterDropdown in sticky toolbar" + pattern: " +Add search/filter toolbar to the gear tab and a shared icon-aware category filter dropdown to both gear and planning tabs. Users can search items by name, filter by category, see result counts, and clear filters individually. + +Purpose: Help users find items quickly as collections grow, and upgrade the planning tab's plain ` for category filtering (lines 277-291) +// - Filters threads by activeTab and categoryFilter +``` + + + + + + + Task 1: Create CategoryFilterDropdown component + src/client/components/CategoryFilterDropdown.tsx + + Create `src/client/components/CategoryFilterDropdown.tsx` -- a searchable dropdown showing categories with Lucide icons. This is a FILTER dropdown, NOT the form-based `CategoryPicker` (which handles creation). Keep them separate per user decision. + + **Props:** + ```typescript + interface CategoryFilterDropdownProps { + value: number | null; // selected category ID, null = "All categories" + onChange: (value: number | null) => void; + categories: Array<{ id: number; name: string; icon: string }>; + } + ``` + + **Structure:** + - **Trigger button**: Shows "All categories" with a chevron-down icon when `value` is null. Shows the selected category's `LucideIcon` (size 14) + name when a category is selected. Style: `px-3 py-2 border border-gray-200 rounded-lg text-sm bg-white` (matching search input height). Include a small clear "x" button on the right when a category is selected (clicking it calls `onChange(null)` without opening the dropdown). + - **Dropdown panel**: Opens below the trigger, `position: absolute`, `z-20`, white bg, border, rounded-lg, shadow-lg, max-height with overflow-y-auto. Width matches trigger or has a reasonable min-width (~220px). + - **Search input inside dropdown**: Text input at top of dropdown, placeholder "Search categories...", filters the category list as user types. Auto-focused when dropdown opens. + - **Option list**: "All categories" as first option (selecting calls `onChange(null)` and closes). Then each category: `LucideIcon` (size 16) + category name. Highlight the currently selected option with a subtle bg color. Hover state on each option. + - **Click-outside dismiss**: Use `containerRef` + `useEffect` mousedown listener pattern (same as `CategoryPicker`). Also close on Escape keydown. + - **State reset**: Clear internal search text when dropdown closes. + + **Do NOT:** + - Reuse or modify `CategoryPicker.tsx` + - Add category creation capability + - Use Zustand for dropdown open/closed state (use local `useState`) + + + cd /home/jlmak/Projects/jlmak/GearBox && bun run lint + + CategoryFilterDropdown.tsx exists with searchable dropdown, Lucide icons per option, "All categories" first option, click-outside dismiss, clear button on trigger, and Escape to close. Lint passes. + + + + Task 2: Add search/filter toolbar to CollectionView and replace select in PlanningView + src/client/routes/collection/index.tsx + + Modify `src/client/routes/collection/index.tsx` to add search and filtering to `CollectionView` and upgrade `PlanningView`'s category filter. + + **CollectionView changes:** + + 1. Add filter state at the top of `CollectionView`: + ```typescript + const [searchText, setSearchText] = useState(""); + const [categoryFilter, setCategoryFilter] = useState(null); + ``` + + 2. Add `useCategories` hook: `const { data: categories } = useCategories();` + + 3. Add filtered items computation with `useMemo`: + ```typescript + 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]); + ``` + Import `useMemo` from React, import `useCategories` from hooks. + + 4. Compute filter state: + ```typescript + const hasActiveFilters = searchText !== "" || categoryFilter !== null; + ``` + + 5. Add sticky toolbar ABOVE the existing item grid rendering (after loading/empty checks, before the grouped items). The toolbar only shows when there are items: + ```jsx +
+
+
+ setSearchText(e.target.value)} + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent" + /> + {searchText && ( + + )} +
+ +
+ {hasActiveFilters && ( +

+ Showing {filteredItems.length} of {items?.length ?? 0} items +

+ )} +
+ ``` + + 6. Conditional rendering based on filter state: + - **When `hasActiveFilters` is true**: Render `filteredItems` as a flat grid (no category grouping, no `CategoryHeader`). If `filteredItems.length === 0`, show "No items match your search" centered text message. + - **When `hasActiveFilters` is false**: Keep existing category-grouped rendering exactly as-is (the `groupedItems` Map pattern), but use `filteredItems` as the source (which equals all items when no filters). + + **PlanningView changes:** + + 1. Import `CategoryFilterDropdown` from `../../components/CategoryFilterDropdown`. + 2. Replace the native `