Files
GearBox/.planning/phases/11-candidate-ranking/11-02-PLAN.md

379 lines
19 KiB
Markdown

---
phase: 11-candidate-ranking
plan: "02"
type: execute
wave: 2
depends_on: ["11-01"]
files_modified:
- src/client/stores/uiStore.ts
- src/client/hooks/useCandidates.ts
- src/client/components/CandidateListItem.tsx
- src/client/components/CandidateCard.tsx
- src/client/routes/threads/$threadId.tsx
autonomous: false
requirements: [RANK-01, RANK-02, RANK-04, RANK-05]
must_haves:
truths:
- "User can drag a candidate card to a new position in list view and it persists after page refresh"
- "Top 3 candidates display gold, silver, and bronze medal badges"
- "Rank badges appear in both list view and grid view"
- "Drag handles are hidden and drag is disabled on resolved threads"
- "Rank badges remain visible on resolved threads"
- "User can toggle between list and grid view"
- "List view is the default view"
artifacts:
- path: "src/client/components/CandidateListItem.tsx"
provides: "Horizontal list-view candidate card with drag handle and rank badge"
min_lines: 60
- path: "src/client/routes/threads/$threadId.tsx"
provides: "View toggle + Reorder.Group wrapping candidates + tempItems flicker prevention"
contains: "Reorder.Group"
- path: "src/client/hooks/useCandidates.ts"
provides: "useReorderCandidates mutation hook"
contains: "useReorderCandidates"
- path: "src/client/stores/uiStore.ts"
provides: "candidateViewMode state"
contains: "candidateViewMode"
- path: "src/client/components/CandidateCard.tsx"
provides: "Rank badge on grid-view cards"
contains: "RankBadge"
key_links:
- from: "src/client/routes/threads/$threadId.tsx"
to: "src/client/hooks/useCandidates.ts"
via: "useReorderCandidates(threadId)"
pattern: "useReorderCandidates"
- from: "src/client/hooks/useCandidates.ts"
to: "/api/threads/:id/candidates/reorder"
via: "apiPatch"
pattern: "apiPatch.*candidates/reorder"
- from: "src/client/routes/threads/$threadId.tsx"
to: "framer-motion"
via: "Reorder.Group + Reorder.Item"
pattern: "Reorder\\.Group"
- from: "src/client/components/CandidateListItem.tsx"
to: "framer-motion"
via: "Reorder.Item + useDragControls"
pattern: "useDragControls"
- from: "src/client/stores/uiStore.ts"
to: "src/client/routes/threads/$threadId.tsx"
via: "candidateViewMode state"
pattern: "candidateViewMode"
---
<objective>
Build the drag-to-reorder UI with list/grid view toggle, CandidateListItem component, framer-motion Reorder integration, rank badges, and resolved-thread guard.
Purpose: Delivers the user-facing ranking experience: drag candidates to prioritize, see gold/silver/bronze medals, toggle between compact list and card grid views. All four RANK requirements are covered.
Output: Working drag-to-reorder in list view, rank badges in both views, view toggle, resolved-thread readonly mode.
</objective>
<execution_context>
@/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md
@/home/jlmak/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/11-candidate-ranking/11-CONTEXT.md
@.planning/phases/11-candidate-ranking/11-RESEARCH.md
@.planning/phases/11-candidate-ranking/11-01-SUMMARY.md
<interfaces>
<!-- Interfaces created by Plan 01 that this plan depends on -->
From src/shared/schemas.ts (created in 11-01):
```typescript
export const reorderCandidatesSchema = z.object({
orderedIds: z.array(z.number().int().positive()).min(1),
});
```
From src/shared/types.ts (created in 11-01):
```typescript
export type ReorderCandidates = z.infer<typeof reorderCandidatesSchema>;
```
From src/server/services/thread.service.ts (modified in 11-01):
```typescript
export function reorderCandidates(db, threadId, orderedIds): { success: boolean; error?: string }
// getThreadWithCandidates now returns candidates sorted by sort_order ascending
// createCandidate now assigns sort_order = max + 1000 (appends to bottom)
```
API endpoint (created in 11-01):
```
PATCH /api/threads/:id/candidates/reorder
Body: { orderedIds: number[] }
Response: { success: true } | { error: string } (400)
```
From src/client/lib/api.ts:
```typescript
export async function apiPatch<T>(url: string, body: unknown): Promise<T>;
```
From src/client/hooks/useCandidates.ts (existing):
```typescript
interface CandidateResponse {
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";
pros: string | null; cons: string | null;
createdAt: string; updatedAt: string;
}
```
From src/client/components/CandidateCard.tsx (existing props):
```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: "researching" | "ordered" | "arrived";
onStatusChange: (status: "researching" | "ordered" | "arrived") => void;
pros?: string | null; cons?: string | null;
}
```
From src/client/stores/uiStore.ts (existing patterns):
```typescript
interface UIState {
// ... existing state
// Add: candidateViewMode: "list" | "grid"
// Add: setCandidateViewMode: (mode: "list" | "grid") => void
}
```
From framer-motion (installed v12.37.0):
```typescript
import { Reorder, useDragControls } from "framer-motion";
// Reorder.Group: axis="y", values={items}, onReorder={setItems}
// Reorder.Item: value={item}, dragControls={controls}, dragListener={false}
// useDragControls: controls.start(pointerEvent) on handle's onPointerDown
```
From lucide-react (confirmed available icons):
- grip-vertical (drag handle)
- medal (rank badge)
- layout-list (list view toggle)
- layout-grid (grid view toggle)
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: useReorderCandidates hook + uiStore view mode + CandidateListItem component</name>
<files>src/client/hooks/useCandidates.ts, src/client/stores/uiStore.ts, src/client/components/CandidateListItem.tsx</files>
<action>
1. **useReorderCandidates hook** (`src/client/hooks/useCandidates.ts`):
- Import `apiPatch` from `../lib/api`.
- Add new exported function:
```typescript
export function useReorderCandidates(threadId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { orderedIds: number[] }) =>
apiPatch<{ success: boolean }>(
`/api/threads/${threadId}/candidates/reorder`,
data,
),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["threads", threadId] });
},
});
}
```
2. **uiStore** (`src/client/stores/uiStore.ts`):
- Add to the UIState interface:
```typescript
candidateViewMode: "list" | "grid";
setCandidateViewMode: (mode: "list" | "grid") => void;
```
- Add to the create block:
```typescript
candidateViewMode: "list",
setCandidateViewMode: (mode) => set({ candidateViewMode: mode }),
```
3. **CandidateListItem** (`src/client/components/CandidateListItem.tsx`) — NEW FILE:
- Create a horizontal card component for list view.
- Import `{ Reorder, useDragControls }` from `framer-motion`.
- Import `LucideIcon` from `../lib/iconData`, formatters, hooks (useWeightUnit, useCurrency), useUIStore, StatusBadge.
- Props interface:
```typescript
interface CandidateListItemProps {
candidate: CandidateWithCategory; // The full candidate object from thread.candidates
rank: number; // 1-based position index
isActive: boolean; // thread.status === "active"
onStatusChange: (status: "researching" | "ordered" | "arrived") => void;
}
```
Where `CandidateWithCategory` is the candidate shape from `useThread` response (id, name, weightGrams, priceCents, categoryName, categoryIcon, imageFilename, productUrl, status, pros, cons, etc.). Define this type locally or reference the CandidateResponse + category fields.
- Use `useDragControls()` hook. Return a `Reorder.Item` with `value={candidate}` (the full candidate object, same reference used in Reorder.Group values), `dragControls={controls}`, `dragListener={false}`.
- Layout (horizontal card):
- Outer: `flex items-center gap-3 bg-white rounded-xl border border-gray-100 p-3 hover:border-gray-200 hover:shadow-sm transition-all group`
- LEFT: Drag handle (only if `isActive`): GripVertical icon (size 16), `onPointerDown={(e) => controls.start(e)}`, classes: `cursor-grab active:cursor-grabbing text-gray-300 hover:text-gray-500 touch-none shrink-0`
- RANK BADGE: Inline `RankBadge` component (see below). Shows medal icon for rank 1-3 with gold/silver/bronze colors. Returns null for rank > 3.
- IMAGE THUMBNAIL: 48x48 rounded-lg overflow-hidden shrink-0. If `imageFilename`, show `<img src="/uploads/${imageFilename}" />` with object-cover. Else show `LucideIcon` of `categoryIcon` (size 20) in gray on gray-50 background.
- NAME + BADGES: `flex-1 min-w-0` container.
- Name: `text-sm font-semibold text-gray-900 truncate`
- Badge row: `flex flex-wrap gap-1.5 mt-1` with weight (blue), price (green), category (gray + icon), StatusBadge, pros/cons badge (purple "+/- Notes").
- Use same badge pill classes as CandidateCard.
- ACTION BUTTONS (hover-reveal, right side): Winner (if isActive), Delete, External link (if productUrl). Use same click handlers as CandidateCard (openResolveDialog, openConfirmDeleteCandidate, openExternalLink from uiStore). Classes: `opacity-0 group-hover:opacity-100 transition-opacity` on a flex container.
- Clicking the card body (not handle or action buttons) opens the edit panel: wrap in a clickable area that calls `openCandidateEditPanel(candidate.id)`.
- **RankBadge** (inline helper or small component in same file):
```typescript
const RANK_COLORS = ["#D4AF37", "#C0C0C0", "#CD7F32"]; // gold, silver, bronze
function RankBadge({ rank }: { rank: number }) {
if (rank > 3) return null;
return <LucideIcon name="medal" size={16} className="shrink-0" style={{ color: RANK_COLORS[rank - 1] }} />;
}
```
Export `RankBadge` so it can be reused by CandidateCard in grid view.
</action>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun run lint 2>&1 | head -30</automated>
</verify>
<done>CandidateListItem.tsx created with drag handle, rank badge, horizontal layout. useReorderCandidates hook created. uiStore has candidateViewMode. RankBadge exported. Lint passes.</done>
</task>
<task type="auto">
<name>Task 2: Thread detail page with view toggle, Reorder.Group, rank badges in grid view</name>
<files>src/client/routes/threads/$threadId.tsx, src/client/components/CandidateCard.tsx</files>
<action>
1. **CandidateCard rank badge** (`src/client/components/CandidateCard.tsx`):
- Import `RankBadge` from `./CandidateListItem` (or wherever it's exported).
- Add `rank?: number` to `CandidateCardProps`.
- In the card layout, add `{rank != null && <RankBadge rank={rank} />}` in the badge row (flex-wrap area), positioned as the first badge before weight/price.
2. **Thread detail page** (`src/client/routes/threads/$threadId.tsx`):
- Import `{ Reorder }` from `framer-motion`.
- Import `{ useState, useEffect }` from `react`.
- Import `CandidateListItem` from `../../components/CandidateListItem`.
- Import `useReorderCandidates` from `../../hooks/useCandidates`.
- Import `useUIStore` selector for `candidateViewMode` and `setCandidateViewMode`.
- Import `LucideIcon` (already imported).
- **View toggle** in the header area (after the "Add Candidate" button, or in the thread header row):
- Two icon buttons: LayoutList and LayoutGrid (from Lucide).
- Active button has `bg-gray-200 text-gray-900`, inactive has `text-gray-400 hover:text-gray-600`.
- `onClick` calls `setCandidateViewMode("list")` or `setCandidateViewMode("grid")`.
- Placed inline in a small toggle group: `flex items-center gap-1 bg-gray-100 rounded-lg p-0.5`
- **tempItems pattern** for flicker prevention:
```typescript
const [tempItems, setTempItems] = useState<typeof thread.candidates | null>(null);
const displayItems = tempItems ?? thread.candidates;
// thread.candidates is already sorted by sort_order from server (11-01)
```
Reset tempItems to null whenever `thread.candidates` reference changes (use useEffect if needed, or rely on onSettled clearing).
- **Reorder.Group** (list view, active threads only):
- When `candidateViewMode === "list"` AND candidates exist:
- If `isActive`: Wrap candidates in `<Reorder.Group axis="y" values={displayItems} onReorder={setTempItems} className="flex flex-col gap-2">`.
- Each candidate renders `<CandidateListItem key={candidate.id} candidate={candidate} rank={index + 1} isActive={isActive} onStatusChange={...} />`.
- On Reorder.Item `onDragEnd`, trigger the save. The save function:
```typescript
function handleDragEnd() {
if (!tempItems) return;
reorderMutation.mutate(
{ orderedIds: tempItems.map((c) => c.id) },
{ onSettled: () => setTempItems(null) }
);
}
```
Attach this to `Reorder.Group` via a wrapper that uses `onPointerUp` or pass as prop to `CandidateListItem`. The cleanest approach: use framer-motion's `onDragEnd` prop on each `Reorder.Item` — when any item finishes dragging, if tempItems differs from server data, fire the mutation.
- If `!isActive` (resolved): Render the same `CandidateListItem` components but WITHOUT `Reorder.Group` — just a plain `<div className="flex flex-col gap-2">`. The `isActive={false}` prop hides drag handles. Rank badges remain visible per user decision.
- When `candidateViewMode === "grid"` AND candidates exist:
- Render the existing `<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">` with `CandidateCard` components.
- Pass `rank={index + 1}` to each CandidateCard so rank badges appear in grid view too.
- Both active and resolved threads use static grid (no drag in grid view per user decision).
- **Important framer-motion detail**: `Reorder.Group` `values` must be the same array reference as what you iterate. Use `displayItems` for both `values` and `.map()`. The `Reorder.Item` `value` must be the same object reference (not a copy). Since we use the full candidate object, `value={candidate}` where candidate comes from `displayItems.map(...)`.
- **Empty state**: Keep the existing empty state rendering for both views.
- **useEffect to clear tempItems**: When `thread.candidates` changes (new data from server), clear tempItems:
```typescript
useEffect(() => {
setTempItems(null);
}, [thread?.candidates]);
```
This ensures that when React Query refetches, tempItems is cleared and we render fresh server data.
</action>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun run lint 2>&1 | head -30</automated>
</verify>
<done>Thread detail page renders list/grid toggle. List view has drag-to-reorder via Reorder.Group with tempItems flicker prevention. Grid view shows rank badges. Resolved threads show static list/grid with rank badges but no drag handles. Lint passes.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Verify drag-to-reorder ranking experience</name>
<files>none</files>
<action>
Human verifies the complete drag-to-reorder candidate ranking with list/grid view toggle, rank badges (gold/silver/bronze), auto-save persistence, and resolved thread guard.
</action>
<what-built>Complete drag-to-reorder candidate ranking with list/grid view toggle, rank badges (gold/silver/bronze), auto-save persistence, and resolved thread guard.</what-built>
<how-to-verify>
1. Start dev servers: `bun run dev:client` and `bun run dev:server`
2. Navigate to an existing active thread with 3+ candidates (or create one)
3. Verify list view is the default (vertical stack of horizontal cards)
4. Verify drag handles (grip icon) appear on the left of each card
5. Drag a candidate to a new position — verify it moves smoothly with gap animation
6. Release — verify the new order persists (refresh the page to confirm)
7. Verify the top 3 candidates show gold, silver, bronze medal icons before their names
8. Toggle to grid view — verify rank badges also appear on grid cards
9. Toggle back to list view — verify drag still works
10. Navigate to a resolved thread — verify NO drag handles, but rank badges ARE visible
11. Verify candidates on resolved thread render in their ranked order (static)
</how-to-verify>
<verify>Human confirms all 11 verification steps pass</verify>
<done>All ranking features verified: drag reorder works, persists, shows rank badges in both views, disabled on resolved threads</done>
<resume-signal>Type "approved" or describe any issues</resume-signal>
</task>
</tasks>
<verification>
```bash
# Full test suite green
bun test
# Verify all key files exist
ls src/client/components/CandidateListItem.tsx
grep -n "useReorderCandidates" src/client/hooks/useCandidates.ts
grep -n "candidateViewMode" src/client/stores/uiStore.ts
grep -n "Reorder.Group" src/client/routes/threads/\$threadId.tsx
grep -n "RankBadge" src/client/components/CandidateCard.tsx
# Lint clean
bun run lint
```
</verification>
<success_criteria>
- List view shows horizontal cards with drag handles on active threads
- Drag-to-reorder works via framer-motion Reorder.Group with grip handle
- Order persists after page refresh via PATCH /api/threads/:id/candidates/reorder
- tempItems pattern prevents React Query flicker
- Top 3 candidates display gold (#D4AF37), silver (#C0C0C0), bronze (#CD7F32) medal badges
- Rank badges visible in both list and grid views
- Grid/list toggle works with list as default
- Resolved threads: no drag handles, rank badges visible, static order
- All tests pass, lint clean
</success_criteria>
<output>
After completion, create `.planning/phases/11-candidate-ranking/11-02-SUMMARY.md`
</output>