19 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 11-candidate-ranking | 02 | execute | 2 |
|
|
false |
|
|
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.
<execution_context> @/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md @/home/jlmak/.claude/get-shit-done/templates/summary.md </execution_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.mdFrom src/shared/schemas.ts (created in 11-01):
export const reorderCandidatesSchema = z.object({
orderedIds: z.array(z.number().int().positive()).min(1),
});
From src/shared/types.ts (created in 11-01):
export type ReorderCandidates = z.infer<typeof reorderCandidatesSchema>;
From src/server/services/thread.service.ts (modified in 11-01):
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:
export async function apiPatch<T>(url: string, body: unknown): Promise<T>;
From src/client/hooks/useCandidates.ts (existing):
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):
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):
interface UIState {
// ... existing state
// Add: candidateViewMode: "list" | "grid"
// Add: setCandidateViewMode: (mode: "list" | "grid") => void
}
From framer-motion (installed v12.37.0):
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)
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.
cd /home/jlmak/Projects/jlmak/GearBox && bun run lint 2>&1 | head -30
CandidateListItem.tsx created with drag handle, rank badge, horizontal layout. useReorderCandidates hook created. uiStore has candidateViewMode. RankBadge exported. Lint passes.
Task 2: Thread detail page with view toggle, Reorder.Group, rank badges in grid view
src/client/routes/threads/$threadId.tsx, src/client/components/CandidateCard.tsx
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 && }` 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.
cd /home/jlmak/Projects/jlmak/GearBox && bun run lint 2>&1 | head -30
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.
Task 3: Verify drag-to-reorder ranking experience
none
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.
Complete drag-to-reorder candidate ranking with list/grid view toggle, rank badges (gold/silver/bronze), auto-save persistence, and resolved thread guard.
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)
Human confirms all 11 verification steps pass
All ranking features verified: drag reorder works, persists, shows rank badges in both views, disabled on resolved threads
Type "approved" or describe any issues
```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>