diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index ac7fea6..f7af23d 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -36,7 +36,7 @@ -### 🚧 v1.3 Research & Decision Tools (In Progress) +### v1.3 Research & Decision Tools (In Progress) **Milestone Goal:** Give users the tools to actually decide between candidates — compare details side-by-side, see how a pick impacts their setup, and rank/annotate their options. @@ -58,7 +58,7 @@ 4. All existing tests pass after the schema migration (no column drift in test helper) **Plans:** 1/1 plans complete Plans: -- [ ] 10-01-PLAN.md — Add pros/cons fields through full stack (schema, service, Zod, form, card indicator) +- [x] 10-01-PLAN.md — Add pros/cons fields through full stack (schema, service, Zod, form, card indicator) ### Phase 11: Candidate Ranking **Goal**: Users can drag candidates into a priority order that persists and is visually communicated @@ -69,7 +69,10 @@ Plans: 2. The reordered sequence is still intact after navigating away and returning 3. The top three candidates display gold, silver, and bronze rank badges respectively 4. Drag handles and rank badges are absent on a resolved thread; candidates render in static order -**Plans**: TBD +**Plans:** 2 plans +Plans: +- [ ] 11-01-PLAN.md — Schema migration, reorder service/route, sort_order persistence + tests +- [ ] 11-02-PLAN.md — Drag-to-reorder UI, list/grid toggle, rank badges, resolved-thread guard ### Phase 12: Comparison View **Goal**: Users can view all candidates for a thread side-by-side in a table with relative weight and price deltas @@ -106,7 +109,7 @@ Plans: | 7. Weight Unit Selection | v1.2 | 2/2 | Complete | 2026-03-16 | | 8. Search, Filter, and Candidate Status | v1.2 | 2/2 | Complete | 2026-03-16 | | 9. Weight Classification and Visualization | v1.2 | 2/2 | Complete | 2026-03-16 | -| 10. Schema Foundation + Pros/Cons Fields | 1/1 | Complete | 2026-03-16 | - | -| 11. Candidate Ranking | v1.3 | 0/TBD | Not started | - | +| 10. Schema Foundation + Pros/Cons Fields | v1.3 | 1/1 | Complete | 2026-03-16 | +| 11. Candidate Ranking | v1.3 | 0/2 | Not started | - | | 12. Comparison View | v1.3 | 0/TBD | Not started | - | | 13. Setup Impact Preview | v1.3 | 0/TBD | Not started | - | diff --git a/.planning/phases/11-candidate-ranking/11-01-PLAN.md b/.planning/phases/11-candidate-ranking/11-01-PLAN.md new file mode 100644 index 0000000..a5557f6 --- /dev/null +++ b/.planning/phases/11-candidate-ranking/11-01-PLAN.md @@ -0,0 +1,285 @@ +--- +phase: 11-candidate-ranking +plan: "01" +type: execute +wave: 1 +depends_on: [] +files_modified: + - src/db/schema.ts + - tests/helpers/db.ts + - src/server/services/thread.service.ts + - src/server/routes/threads.ts + - src/shared/schemas.ts + - src/shared/types.ts + - tests/services/thread.service.test.ts + - tests/routes/threads.test.ts +autonomous: true +requirements: [RANK-01, RANK-04, RANK-05] + +must_haves: + truths: + - "Candidates returned from getThreadWithCandidates are ordered by sort_order ascending" + - "Calling reorderCandidates with a new ID sequence updates sort_order values to match that sequence" + - "PATCH /api/threads/:id/candidates/reorder returns 200 and persists new order" + - "reorderCandidates returns error when thread status is not active" + - "New candidates created via createCandidate are appended to end of rank (highest sort_order + 1000)" + artifacts: + - path: "src/db/schema.ts" + provides: "sortOrder REAL column on threadCandidates" + contains: "sortOrder" + - path: "src/shared/schemas.ts" + provides: "reorderCandidatesSchema Zod validator" + contains: "reorderCandidatesSchema" + - path: "src/shared/types.ts" + provides: "ReorderCandidates type" + contains: "ReorderCandidates" + - path: "src/server/services/thread.service.ts" + provides: "reorderCandidates function + ORDER BY sort_order + createCandidate sort_order appending" + exports: ["reorderCandidates"] + - path: "src/server/routes/threads.ts" + provides: "PATCH /:id/candidates/reorder endpoint" + contains: "candidates/reorder" + - path: "tests/helpers/db.ts" + provides: "sort_order column in CREATE TABLE thread_candidates" + contains: "sort_order" + key_links: + - from: "src/server/routes/threads.ts" + to: "src/server/services/thread.service.ts" + via: "reorderCandidates(db, threadId, orderedIds)" + pattern: "reorderCandidates" + - from: "src/server/routes/threads.ts" + to: "src/shared/schemas.ts" + via: "zValidator with reorderCandidatesSchema" + pattern: "reorderCandidatesSchema" + - from: "src/server/services/thread.service.ts" + to: "src/db/schema.ts" + via: "threadCandidates.sortOrder in ORDER BY and UPDATE" + pattern: "threadCandidates\\.sortOrder" +--- + + +Add sort_order column to thread_candidates, implement reorder service and API endpoint, and update candidate ordering throughout the backend. + +Purpose: Provides the persistence layer for drag-to-reorder ranking (RANK-01, RANK-04) and enforces the resolved-thread guard (RANK-05). The frontend plan (11-02) depends on this. +Output: Working PATCH /api/threads/:id/candidates/reorder endpoint, sort_order-based ordering in getThreadWithCandidates, sort_order appending in createCandidate, full test coverage. + + + +@/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/11-candidate-ranking/11-CONTEXT.md +@.planning/phases/11-candidate-ranking/11-RESEARCH.md + + + + +From src/db/schema.ts (threadCandidates table — add sortOrder here): +```typescript +export const threadCandidates = sqliteTable("thread_candidates", { + id: integer("id").primaryKey({ autoIncrement: true }), + threadId: integer("thread_id").notNull().references(() => threads.id, { onDelete: "cascade" }), + name: text("name").notNull(), + weightGrams: real("weight_grams"), + priceCents: integer("price_cents"), + categoryId: integer("category_id").notNull().references(() => categories.id), + notes: text("notes"), + productUrl: text("product_url"), + imageFilename: text("image_filename"), + status: text("status").notNull().default("researching"), + pros: text("pros"), + cons: text("cons"), + createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()), + updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()), +}); +``` + +From src/server/services/thread.service.ts (key functions to modify): +```typescript +type Db = typeof prodDb; +export function getThreadWithCandidates(db: Db, threadId: number) // add .orderBy(threadCandidates.sortOrder) +export function createCandidate(db: Db, threadId: number, data: ...) // add sort_order = max + 1000 +export function resolveThread(db: Db, threadId: number, candidateId: number) // existing status check pattern to reuse +``` + +From src/shared/schemas.ts (existing patterns): +```typescript +export const createCandidateSchema = z.object({ ... }); +export const resolveThreadSchema = z.object({ candidateId: z.number().int().positive() }); +``` + +From src/shared/types.ts (add new type): +```typescript +export type ResolveThread = z.infer; +// Add: export type ReorderCandidates = z.infer; +``` + +From src/server/routes/threads.ts (route pattern): +```typescript +type Env = { Variables: { db?: any } }; +const app = new Hono(); +// Pattern: app.post("/:id/resolve", zValidator("json", resolveThreadSchema), (c) => { ... }); +``` + +From src/client/lib/api.ts: +```typescript +export async function apiPatch(url: string, body: unknown): Promise; +``` + +From tests/helpers/db.ts (thread_candidates CREATE TABLE — add sort_order): +```sql +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', + pros TEXT, + cons TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +) +``` + + + + + + + Task 1: Schema, migration, service layer, and tests for sort_order + reorder + src/db/schema.ts, tests/helpers/db.ts, src/server/services/thread.service.ts, src/shared/schemas.ts, src/shared/types.ts, tests/services/thread.service.test.ts + + - Test: getThreadWithCandidates returns candidates ordered by sort_order ascending (create 3 candidates with different sort_orders, verify order) + - Test: reorderCandidates(db, threadId, [id3, id1, id2]) updates sort_order so querying returns [id3, id1, id2] + - Test: reorderCandidates returns { success: false, error } when thread status is "resolved" + - Test: createCandidate assigns sort_order = max existing sort_order + 1000 (first candidate gets 1000, second gets 2000) + - Test: reorderCandidates returns { success: false } when thread does not exist + + + 1. **Schema** (`src/db/schema.ts`): Add `sortOrder: real("sort_order").notNull().default(0)` to the `threadCandidates` table definition. + + 2. **Migration**: Run `bun run db:generate` to produce the Drizzle migration SQL. Then apply it with `bun run db:push`. After applying, run a data backfill to space existing candidates: + ```sql + UPDATE thread_candidates SET sort_order = ( + SELECT (ROW_NUMBER() OVER (PARTITION BY thread_id ORDER BY created_at)) * 1000 + FROM thread_candidates AS tc2 WHERE tc2.id = thread_candidates.id + ); + ``` + Execute this backfill via the Drizzle migration custom SQL or a small script. + + 3. **Test helper** (`tests/helpers/db.ts`): Add `sort_order REAL NOT NULL DEFAULT 0` to the CREATE TABLE thread_candidates statement (after the `cons TEXT` line, before `created_at`). + + 4. **Zod schema** (`src/shared/schemas.ts`): Add: + ```typescript + export const reorderCandidatesSchema = z.object({ + orderedIds: z.array(z.number().int().positive()).min(1), + }); + ``` + + 5. **Types** (`src/shared/types.ts`): Add import of `reorderCandidatesSchema` and: + ```typescript + export type ReorderCandidates = z.infer; + ``` + + 6. **Service** (`src/server/services/thread.service.ts`): + - In `getThreadWithCandidates`: Add `.orderBy(threadCandidates.sortOrder)` to the candidateList query (after `.where()`). + - In `createCandidate`: Before inserting, query `MAX(sort_order)` from threadCandidates where threadId matches. Set `sortOrder: (maxRow?.maxOrder ?? 0) + 1000` in the `.values()` call. Use `sql` template for the MAX query. + - Add new exported function `reorderCandidates(db, threadId, orderedIds)`: + - Wrap in `db.transaction()`. + - Verify thread exists and `status === "active"` (return `{ success: false, error: "Thread not active" }` if not). + - Loop through `orderedIds`, UPDATE each candidate's `sortOrder` to `(index + 1) * 1000`. + - Return `{ success: true }`. + + 7. **Tests** (`tests/services/thread.service.test.ts`): + - Import `reorderCandidates` from the service. + - Add a new `describe("reorderCandidates", () => { ... })` block with the behavior tests listed above. + - Add test for `getThreadWithCandidates` ordering by sort_order (create candidates, set different sort_orders manually via db, verify order). + - Add test for `createCandidate` sort_order appending. + + + bun test tests/services/thread.service.test.ts + + All existing thread service tests pass (28+) plus 5+ new tests for reorderCandidates, sort_order ordering, sort_order appending. sortOrder column exists in schema with REAL type. + + + + Task 2: PATCH reorder route + route tests + src/server/routes/threads.ts, tests/routes/threads.test.ts + + - Test: PATCH /api/threads/:id/candidates/reorder with valid orderedIds returns 200 + { success: true } + - Test: After PATCH reorder, GET /api/threads/:id returns candidates in the new order + - Test: PATCH /api/threads/:id/candidates/reorder on a resolved thread returns 400 + - Test: PATCH /api/threads/:id/candidates/reorder with empty body returns 400 (Zod validation) + + + 1. **Route** (`src/server/routes/threads.ts`): + - Import `reorderCandidatesSchema` from `../../shared/schemas.ts`. + - Import `reorderCandidates` from `../services/thread.service.ts`. + - Add PATCH route BEFORE the resolution route (to avoid param conflicts): + ```typescript + app.patch( + "/:id/candidates/reorder", + zValidator("json", reorderCandidatesSchema), + (c) => { + const db = c.get("db"); + const threadId = Number(c.req.param("id")); + const { orderedIds } = c.req.valid("json"); + const result = reorderCandidates(db, threadId, orderedIds); + if (!result.success) return c.json({ error: result.error }, 400); + return c.json({ success: true }); + }, + ); + ``` + + 2. **Route tests** (`tests/routes/threads.test.ts`): + - Add a new `describe("PATCH /api/threads/:id/candidates/reorder", () => { ... })` block. + - Test: Create a thread with 3 candidates via API, PATCH reorder with reversed IDs, GET thread and verify candidates array is in the new order. + - Test: Resolve a thread, then PATCH reorder returns 400. + - Test: PATCH with invalid body (empty orderedIds array or missing field) returns 400. + + + bun test tests/routes/threads.test.ts + + PATCH /api/threads/:id/candidates/reorder returns 200 on active thread + persists order. Returns 400 on resolved thread. All existing route tests still pass. + + + + + +```bash +# Full test suite — all existing + new tests green +bun test + +# Verify sort_order column exists in schema +grep -n "sortOrder" src/db/schema.ts + +# Verify reorder endpoint registered +grep -n "candidates/reorder" src/server/routes/threads.ts + +# Verify test helper updated +grep -n "sort_order" tests/helpers/db.ts +``` + + + +- sort_order REAL column added to threadCandidates schema and test helper +- getThreadWithCandidates returns candidates sorted by sort_order ascending +- createCandidate appends new candidates at max sort_order + 1000 +- reorderCandidates service function updates sort_order in transaction, rejects resolved threads +- PATCH /api/threads/:id/candidates/reorder validated with Zod, returns 200/400 correctly +- All existing tests pass with zero regressions + 8+ new tests + + + +After completion, create `.planning/phases/11-candidate-ranking/11-01-SUMMARY.md` + diff --git a/.planning/phases/11-candidate-ranking/11-02-PLAN.md b/.planning/phases/11-candidate-ranking/11-02-PLAN.md new file mode 100644 index 0000000..6d64297 --- /dev/null +++ b/.planning/phases/11-candidate-ranking/11-02-PLAN.md @@ -0,0 +1,378 @@ +--- +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" +--- + + +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. + + + +@/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/11-candidate-ranking/11-CONTEXT.md +@.planning/phases/11-candidate-ranking/11-RESEARCH.md +@.planning/phases/11-candidate-ranking/11-01-SUMMARY.md + + + + +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; +``` + +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(url: string, body: unknown): Promise; +``` + +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) + + + + + + + Task 1: useReorderCandidates hook + uiStore view mode + CandidateListItem component + src/client/hooks/useCandidates.ts, src/client/stores/uiStore.ts, src/client/components/CandidateListItem.tsx + + 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 `` 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 ; + } + ``` + 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(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 ``. + - Each candidate renders ``. + - 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 `
`. The `isActive={false}` prop hides drag handles. Rank badges remain visible per user decision. + + - When `candidateViewMode === "grid"` AND candidates exist: + - Render the existing `
` 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 +``` + + + +- 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 + + + +After completion, create `.planning/phases/11-candidate-ranking/11-02-SUMMARY.md` +