---
phase: 11-candidate-ranking
verified: 2026-03-16T23:30:00Z
status: complete
score: 11/11 must-haves verified
re_verification:
  previous_status: gaps_found
  previous_score: 9/11
  gaps_closed:
    - "User can drag a candidate card to a new position in list view and it persists after page refresh — onPointerUp={handleDragEnd} is now correctly on the active <Reorder.Group> (line 198)"
  gaps_remaining: []
  regressions: []
human_verification:
  - test: "Drag a candidate on an active thread and refresh"
    expected: "Dragged order is preserved after page reload (new order loaded from server)"
    why_human: "Smooth drag animation, gap preview, pointer-event timing, and actual persistence need visual inspection and interaction"
  - test: "Drag handles visibility on resolved vs active threads"
    expected: "Active threads show GripVertical drag handles; resolved threads show no drag handles but rank badges remain"
    why_human: "CSS visibility and conditional rendering need visual verification"
  - test: "Top 3 rank badges appearance"
    expected: "Gold (#D4AF37), silver (#C0C0C0), bronze (#CD7F32) medal icons appear on positions 1, 2, 3 in both list and grid views"
    why_human: "Color rendering and icon display need visual confirmation"
---

# Phase 11: Candidate Ranking Verification Report

**Phase Goal:** Users can drag candidates into a priority order that persists and is visually communicated
**Verified:** 2026-03-16T23:30:00Z
**Status:** human_needed
**Re-verification:** Yes — after gap closure

## Re-verification Summary

Previous status was `gaps_found` (score 9/11). The one critical blocker was:

> `handleDragEnd` (which calls `reorderMutation.mutate`) was wired to the resolved-thread `<div>` via `onPointerUp`, not to the active-thread `<Reorder.Group>`. Dragging updated `tempItems` visually but never fired the mutation.

**Fix verified:** `src/client/routes/threads/$threadId.tsx` line 198 now has `onPointerUp={handleDragEnd}` on the `<Reorder.Group>` for the active-thread path. The resolved-thread `<div>` (lines 217-233) has no `onPointerUp` handler. The fix is correct and complete.

All 11 truths now pass automated checks. 135/135 tests pass. No regressions detected.

---

## Goal Achievement

### Observable Truths

| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | Candidates returned from getThreadWithCandidates are ordered by sort_order ascending | VERIFIED | `thread.service.ts:90` uses `.orderBy(asc(threadCandidates.sortOrder))` |
| 2 | Calling reorderCandidates with a new ID sequence updates sort_order values | VERIFIED | `reorderCandidates` loops `orderedIds`, sets `sortOrder: (i+1)*1000` per candidate in a transaction (`thread.service.ts:240`) |
| 3 | PATCH /api/threads/:id/candidates/reorder returns 200 and persists new order | VERIFIED | Route at `threads.ts:129-140`; Zod-validated; returns `{ success: true }` or 400 |
| 4 | reorderCandidates returns error when thread status is not active | VERIFIED | `thread.service.ts:234` checks `thread.status !== "active"`, returns `{ success: false, error: "Thread not active" }` |
| 5 | New candidates appended to end of rank (max sort_order + 1000) | VERIFIED | `createCandidate` queries MAX, sets `sortOrder: (maxRow?.maxOrder ?? 0) + 1000` (`thread.service.ts:150,171`) |
| 6 | User can drag a candidate card to a new position in list view and it persists after page refresh | VERIFIED (code) | `handleDragEnd` is now wired via `onPointerUp={handleDragEnd}` on `<Reorder.Group>` at `$threadId.tsx:198`. Mutation fires on pointer-up after drag. Persistence needs human confirmation. |
| 7 | Top 3 candidates display gold, silver, and bronze medal badges | VERIFIED (code) | `RankBadge` in `CandidateListItem.tsx:37-47` renders medal icon with `RANK_COLORS` for rank 1-3, returns null for rank > 3. Visual confirmation needed. |
| 8 | Rank badges appear in both list view and grid view | VERIFIED | `CandidateCard.tsx:165` renders `{rank != null && <RankBadge rank={rank} />}`; `$threadId.tsx:258` passes `rank={index + 1}` to all grid cards |
| 9 | Drag handles are hidden and drag is disabled on resolved threads | VERIFIED | `CandidateListItem.tsx:73` renders drag handle only if `isActive`; resolved threads render plain `<div>` (not `Reorder.Group`) at `$threadId.tsx:217` |
| 10 | Rank badges remain visible on resolved threads | VERIFIED | Resolved thread renders `<CandidateListItem isActive={false}>` which always renders `<RankBadge rank={rank} />` at line 85 |
| 11 | User can toggle between list and grid view with list as default | VERIFIED | `uiStore.ts:112` initializes `candidateViewMode: "list"`; toggle buttons in `$threadId.tsx:146-172` call `setCandidateViewMode` |

**Score:** 11/11 truths verified (all pass automated checks; 3 require human visual confirmation)

---

## Required Artifacts

### Plan 11-01 Artifacts

| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `src/db/schema.ts` | sortOrder REAL column on threadCandidates | VERIFIED | Line 64: `sortOrder: real("sort_order").notNull().default(0)` |
| `src/shared/schemas.ts` | reorderCandidatesSchema Zod validator | VERIFIED | Line 66: `export const reorderCandidatesSchema = z.object({ orderedIds: ... })` |
| `src/shared/types.ts` | ReorderCandidates type | VERIFIED | Line 37: `export type ReorderCandidates = z.infer<typeof reorderCandidatesSchema>` |
| `src/server/services/thread.service.ts` | reorderCandidates function exported | VERIFIED | Lines 220+: full implementation exported; sortOrder used at lines 90, 150, 171, 240 |
| `src/server/routes/threads.ts` | PATCH /:id/candidates/reorder endpoint | VERIFIED | Lines 129-140: registered with Zod validation |
| `tests/helpers/db.ts` | sort_order column in CREATE TABLE | VERIFIED | Line 60: `sort_order REAL NOT NULL DEFAULT 0` |

### Plan 11-02 Artifacts

| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `src/client/components/CandidateListItem.tsx` | Horizontal list card with drag handle and rank badge (min 60 lines) | VERIFIED | 211 lines; `Reorder.Item` with `useDragControls`, drag handle (lines 73-82), `RankBadge` (line 85) |
| `src/client/routes/threads/$threadId.tsx` | Reorder.Group wrapping + tempItems pattern + handleDragEnd on Reorder.Group | VERIFIED | `Reorder.Group` at line 194 with `onPointerUp={handleDragEnd}` at line 198; `tempItems` pattern at lines 28-37, 76 |
| `src/client/hooks/useCandidates.ts` | useReorderCandidates mutation hook | VERIFIED | Lines 66-78: calls `apiPatch` to `candidates/reorder`, invalidates query on settled |
| `src/client/stores/uiStore.ts` | candidateViewMode state | VERIFIED | Lines 53-54 (interface), 112-113 (implementation): default "list" |
| `src/client/components/CandidateCard.tsx` | RankBadge on grid cards | VERIFIED | Imports `RankBadge` from `CandidateListItem` (line 6); renders at line 165 when `rank != null` |

---

## Key Link Verification

### Plan 11-01 Key Links

| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `threads.ts` | `thread.service.ts` | `reorderCandidates(db, threadId, orderedIds)` | WIRED | Line 136 imports and calls `reorderCandidates` |
| `threads.ts` | `schemas.ts` | `zValidator with reorderCandidatesSchema` | WIRED | Line 8 imports `reorderCandidatesSchema`; line 131 uses `zValidator("json", reorderCandidatesSchema)` |
| `thread.service.ts` | `schema.ts` | `threadCandidates.sortOrder in ORDER BY and UPDATE` | WIRED | Line 90 uses `asc(threadCandidates.sortOrder)`; line 240 sets `sortOrder: (i+1)*1000` |

### Plan 11-02 Key Links

| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `threads/$threadId.tsx` | `useCandidates.ts` | `useReorderCandidates(threadId)` | WIRED | Line 7 imports, line 26 calls `useReorderCandidates(threadId)` |
| `useCandidates.ts` | `/api/threads/:id/candidates/reorder` | `apiPatch` | WIRED | Lines 70-73: `apiPatch<{ success: boolean }>(\`/api/threads/${threadId}/candidates/reorder\`, data)` |
| `threads/$threadId.tsx` | `framer-motion` | `Reorder.Group + Reorder.Item` | WIRED | Line 2 imports `{ Reorder }`; line 194 uses `<Reorder.Group>` |
| `CandidateListItem.tsx` | `framer-motion` | `Reorder.Item + useDragControls` | WIRED | Line 1 imports `{ Reorder, useDragControls }`; line 55 calls `useDragControls()` |
| `uiStore.ts` | `threads/$threadId.tsx` | `candidateViewMode state` | WIRED | Lines 23-24 consume `candidateViewMode`/`setCandidateViewMode`; lines 148-170 use them in toggle buttons |
| `Reorder.Group` | `reorderMutation` | `handleDragEnd via onPointerUp` | WIRED | `onPointerUp={handleDragEnd}` is on the active-thread `<Reorder.Group>` at line 198. `handleDragEnd` at lines 78-84 calls `reorderMutation.mutate`. Fix confirmed. |

---

## Requirements Coverage

| Requirement | Source Plan | Description | Status | Evidence |
|-------------|-------------|-------------|--------|----------|
| RANK-01 | 11-01, 11-02 | User can drag candidates to reorder priority ranking | SATISFIED | Drag via framer-motion `Reorder.Group`; `handleDragEnd` now wired at `onPointerUp` on active `Reorder.Group` (line 198); mutation fires `PATCH /api/threads/:id/candidates/reorder` |
| RANK-02 | 11-02 | Top 3 ranked candidates display rank badges (gold, silver, bronze) | SATISFIED | `RankBadge` renders medal icon with `RANK_COLORS`; used in both `CandidateListItem` (line 85) and `CandidateCard` (line 165) |
| RANK-04 | 11-01, 11-02 | Candidate rank order persists across sessions | SATISFIED | `sort_order` column in DB; `reorderCandidates` service updates it in a transaction; React Query invalidates on `onSettled` so next load fetches fresh sorted order |
| RANK-05 | 11-01, 11-02 | Drag handles and ranking disabled on resolved threads | SATISFIED | `CandidateListItem.tsx:73` renders drag handle only if `isActive`; resolved threads use plain `<div>` without `Reorder.Group`; service returns 400 if thread not active |

Note: RANK-03 (pros/cons fields) was handled in Phase 10 and is not part of Phase 11.

---

## Anti-Patterns Found

No blockers or warnings detected in the fixed code.

| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| — | — | — | — | No anti-patterns found |

Previously identified blockers have been resolved: `onPointerUp={handleDragEnd}` is now correctly placed on the active `<Reorder.Group>` and absent from the resolved-thread `<div>`.

---

## Human Verification Required

### 1. Drag persistence after refresh

**Test:** Open an active thread with 3+ candidates, drag a candidate to a different position (e.g. drag position 3 to position 1), then refresh the page.
**Expected:** The new order is preserved after refresh. The `PATCH /api/threads/:id/candidates/reorder` call fires on pointer-up, and the invalidated React Query refetch loads the persisted sort order.
**Why human:** Real-time drag animation quality, gap animation between items, pointer-event timing, and the full round-trip to the server cannot be confirmed by static code analysis.

### 2. Gold/silver/bronze badge colors

**Test:** Open an active thread with 3+ candidates and view in list mode.
**Expected:** Position 1 shows a gold medal icon (`#D4AF37`), position 2 shows silver (`#C0C0C0`), position 3 shows bronze (`#CD7F32`). Positions 4 and above show no badge. Toggle to grid view and verify the same badges appear on the first 3 cards.
**Why human:** Hex color rendering accuracy and icon (medal) correctness need visual confirmation.

### 3. Drag handle visibility on resolved threads

**Test:** Navigate to a resolved thread in list view.
**Expected:** No GripVertical drag handle icons are visible. Gold/silver/bronze rank badges are still present on the top 3 candidates in their sorted order. Candidates cannot be dragged.
**Why human:** Conditional rendering of drag handles and static-only resolved state need visual verification.

---

## Gap Closure Confirmation

The single gap from the previous verification has been closed:

**Gap:** `onPointerUp={handleDragEnd}` was on the resolved-thread `<div>` (isActive=false path) only; the active `<Reorder.Group>` had no handler to trigger the mutation.

**Fix:** `src/client/routes/threads/$threadId.tsx` line 198 — `onPointerUp={handleDragEnd}` is now on `<Reorder.Group axis="y" values={displayItems} onReorder={setTempItems} onPointerUp={handleDragEnd}>`. The resolved-thread `<div>` at lines 217-233 has no `onPointerUp`. The wiring is correct.

**Regression check:** 135/135 tests pass. All previously-verified artifacts and key links remain intact.

---

_Verified: 2026-03-16T23:30:00Z_
_Verifier: Claude (gsd-verifier)_
_Re-verification: Yes — gap closure after previous gaps_found verdict_
