docs(phase-11): research candidate ranking phase
This commit is contained in:
540
.planning/phases/11-candidate-ranking/11-RESEARCH.md
Normal file
540
.planning/phases/11-candidate-ranking/11-RESEARCH.md
Normal file
@@ -0,0 +1,540 @@
|
|||||||
|
# Phase 11: Candidate Ranking - Research
|
||||||
|
|
||||||
|
**Researched:** 2026-03-16
|
||||||
|
**Domain:** Drag-to-reorder UI + fractional indexing persistence
|
||||||
|
**Confidence:** HIGH
|
||||||
|
|
||||||
|
<user_constraints>
|
||||||
|
## User Constraints (from CONTEXT.md)
|
||||||
|
|
||||||
|
### Locked Decisions
|
||||||
|
|
||||||
|
**Card layout and view toggle**
|
||||||
|
- Add a grid/list view toggle in the thread header (list view is default)
|
||||||
|
- List view: vertical stack of horizontal cards (image thumbnail on left, name + badges on right) — enables drag-to-reorder
|
||||||
|
- Grid view: current 3-column responsive card layout preserved
|
||||||
|
- Both views render candidates in rank order (sort_order ascending)
|
||||||
|
- Rank badges visible in both views
|
||||||
|
|
||||||
|
**Drag handle design**
|
||||||
|
- Always-visible GripVertical icon (Lucide) on the left side of each list-view card
|
||||||
|
- Grip icon color: muted gray (text-gray-300), darkens to text-gray-500 on hover
|
||||||
|
- Cursor changes to 'grab' on hover, 'grabbing' during drag
|
||||||
|
- Drag feedback: elevated card with shadow + scale-up effect; other cards animate to show drop target gap (standard framer-motion Reorder behavior)
|
||||||
|
- On resolved threads: grip icon disappears entirely (not disabled/grayed)
|
||||||
|
- Drag only available in list view (grid view has no drag handles)
|
||||||
|
|
||||||
|
**Rank badge style**
|
||||||
|
- Medal icons (Lucide 'medal') in gold (#D4AF37), silver (#C0C0C0), bronze (#CD7F32) for top 3 candidates
|
||||||
|
- Positioned inline before the candidate name text
|
||||||
|
- Candidates ranked 4th and below show no rank indicator — position implied by list order
|
||||||
|
- On resolved threads: rank badges remain visible (static, read-only) — user prefers retrospective visibility
|
||||||
|
|
||||||
|
**Sort order and persistence**
|
||||||
|
- Schema migration adds `sort_order REAL NOT NULL DEFAULT 0` to `thread_candidates`
|
||||||
|
- Migration initializes existing candidates with spaced values (1000, 2000, 3000...) ordered by `created_at`
|
||||||
|
- Fractional indexing: only the moved item gets a single UPDATE (midpoint between neighbors)
|
||||||
|
- New candidates added to a thread get the highest sort_order (appended to bottom of rank)
|
||||||
|
- Auto-save on drop — no "Save order" button; reorder persists immediately via `PATCH /api/threads/:id/candidates/reorder`
|
||||||
|
- `tempItems` local state pattern: render from `tempItems ?? queryData.candidates`; clear on mutation `onSettled` — prevents React Query flicker
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
- Exact horizontal card dimensions and spacing in list view
|
||||||
|
- Grid/list toggle icon style and placement
|
||||||
|
- Drag animation timing and spring config
|
||||||
|
- Image thumbnail size in list view cards
|
||||||
|
- How action buttons (Winner, Delete, Link) adapt to horizontal card layout
|
||||||
|
- Keyboard accessibility for reordering (arrow keys to move)
|
||||||
|
|
||||||
|
### Deferred Ideas (OUT OF SCOPE)
|
||||||
|
None — discussion stayed within phase scope
|
||||||
|
</user_constraints>
|
||||||
|
|
||||||
|
<phase_requirements>
|
||||||
|
## Phase Requirements
|
||||||
|
|
||||||
|
| ID | Description | Research Support |
|
||||||
|
|----|-------------|-----------------|
|
||||||
|
| RANK-01 | User can drag candidates to reorder priority ranking within a thread | framer-motion Reorder.Group/Item handles drag + onReorder callback; fractional indexing PATCH saves order |
|
||||||
|
| RANK-02 | Top 3 ranked candidates display rank badges (gold, silver, bronze) | sort_order ascending sort gives rank position; Lucide `medal` icon confirmed available; CSS inline-color via style prop |
|
||||||
|
| RANK-04 | Candidate rank order persists across sessions | `sort_order REAL` column + Drizzle migration + `getThreadWithCandidates` ORDER BY sort_order; tempItems pattern prevents RQ flicker |
|
||||||
|
| RANK-05 | Drag handles and ranking are disabled on resolved threads | `isActive` prop already flows through `$threadId.tsx`; grip icon conditional render; Reorder.Group only rendered when isActive |
|
||||||
|
</phase_requirements>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Phase 11 adds drag-to-reorder ranking for research thread candidates. The core mechanism is framer-motion's `Reorder.Group` / `Reorder.Item` components (already installed at v12.37.0 — no new dependencies), combined with a `sort_order REAL` column on `thread_candidates` and a fractional indexing strategy that writes only one row per reorder.
|
||||||
|
|
||||||
|
The drag handle pattern requires `useDragControls` from framer-motion so the drag is initiated only from the GripVertical icon, not from tapping anywhere on the card. The `tempItems` local state pattern prevents a visible flicker between optimistic UI and React Query re-fetch.
|
||||||
|
|
||||||
|
The phase introduces a grid/list view toggle (defaulting to list). The existing `CandidateCard` component handles grid view unchanged; a new `CandidateListItem` component (or a variant prop on `CandidateCard`) provides the horizontal list-view layout with the drag handle and rank badge.
|
||||||
|
|
||||||
|
**Primary recommendation:** Implement in this order — schema migration, service update, Zod schema + route, hook, then UI (view toggle, `CandidateListItem`, rank badge). This matches the established field-addition ladder pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard Stack
|
||||||
|
|
||||||
|
### Core
|
||||||
|
| Library | Version | Purpose | Why Standard |
|
||||||
|
|---------|---------|---------|--------------|
|
||||||
|
| framer-motion | ^12.37.0 (installed) | Drag-to-reorder via `Reorder.Group`/`Reorder.Item`; `useDragControls` for handle-based drag | Already in project; Reorder API is purpose-built for this pattern — no additional install |
|
||||||
|
| Drizzle ORM | installed | Schema migration + `ORDER BY sort_order` query | Project ORM; REAL type required for fractional indexing |
|
||||||
|
| @tanstack/react-query | installed | `useReorderCandidates` mutation + cache invalidation | Project data-fetch layer |
|
||||||
|
| Zustand | installed | `candidateViewMode: 'list' | 'grid'` in uiStore | Project UI state pattern |
|
||||||
|
| lucide-react | installed | GripVertical, Medal, LayoutList, LayoutGrid icons | All icons confirmed present in installed version |
|
||||||
|
|
||||||
|
### No New Dependencies
|
||||||
|
This phase requires zero new npm packages. framer-motion, React Query, Zustand, and Lucide are all already installed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### Recommended Project Structure Changes
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── db/schema.ts # Add sortOrder: real("sort_order")
|
||||||
|
├── server/
|
||||||
|
│ ├── services/thread.service.ts # Add reorderCandidates(), update getCandidates ORDER BY
|
||||||
|
│ └── routes/threads.ts # Add PATCH /:id/candidates/reorder
|
||||||
|
├── shared/
|
||||||
|
│ ├── schemas.ts # Add reorderCandidatesSchema
|
||||||
|
│ └── types.ts # Add ReorderCandidates type
|
||||||
|
├── client/
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── CandidateCard.tsx # Unchanged (grid view)
|
||||||
|
│ │ └── CandidateListItem.tsx # NEW: horizontal list-view card with drag handle
|
||||||
|
│ ├── hooks/useCandidates.ts # Add useReorderCandidates mutation
|
||||||
|
│ ├── routes/threads/$threadId.tsx # Add view toggle, Reorder.Group, tempItems pattern
|
||||||
|
│ └── stores/uiStore.ts # Add candidateViewMode state
|
||||||
|
└── tests/
|
||||||
|
├── helpers/db.ts # Add sort_order column to CREATE TABLE
|
||||||
|
└── services/thread.service.test.ts # Tests for reorderCandidates()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 1: framer-motion Reorder with Drag Handle
|
||||||
|
|
||||||
|
The `Reorder.Group` fires `onReorder` whenever a drag completes. The `useDragControls` hook
|
||||||
|
lets the drag be triggered only from the grip icon. Wrap each item with `Reorder.Item` and
|
||||||
|
attach `dragControls` to it.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Source: framer-motion dist/types/index.d.ts (confirmed in installed v12.37.0)
|
||||||
|
import { Reorder, useDragControls } from "framer-motion";
|
||||||
|
|
||||||
|
// In ThreadDetailPage — list view:
|
||||||
|
const [tempItems, setTempItems] = useState<Candidate[] | null>(null);
|
||||||
|
const displayItems = tempItems ?? thread.candidates; // sorted by sort_order from server
|
||||||
|
|
||||||
|
<Reorder.Group
|
||||||
|
axis="y"
|
||||||
|
values={displayItems}
|
||||||
|
onReorder={setTempItems} // updates local order instantly
|
||||||
|
className="flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
{displayItems.map((candidate, index) => (
|
||||||
|
<CandidateListItem
|
||||||
|
key={candidate.id}
|
||||||
|
candidate={candidate}
|
||||||
|
rank={index + 1}
|
||||||
|
isActive={isActive}
|
||||||
|
onReorderSave={() => saveOrder(tempItems)} // called onDragEnd
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Reorder.Group>
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In CandidateListItem — drag handle via useDragControls:
|
||||||
|
// Source: framer-motion dist/types/index.d.ts
|
||||||
|
import { Reorder, useDragControls } from "framer-motion";
|
||||||
|
|
||||||
|
function CandidateListItem({ candidate, rank, isActive, ... }) {
|
||||||
|
const controls = useDragControls();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Reorder.Item value={candidate} dragControls={controls} dragListener={false}>
|
||||||
|
<div className="flex items-center gap-3 bg-white rounded-xl border border-gray-100 p-3">
|
||||||
|
{/* Drag handle — only visible on active threads */}
|
||||||
|
{isActive && (
|
||||||
|
<div
|
||||||
|
onPointerDown={(e) => controls.start(e)}
|
||||||
|
className="cursor-grab active:cursor-grabbing text-gray-300 hover:text-gray-500 touch-none"
|
||||||
|
>
|
||||||
|
<LucideIcon name="grip-vertical" size={16} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Rank badge — top 3 only, visible on resolved too */}
|
||||||
|
{rank <= 3 && <RankBadge rank={rank} />}
|
||||||
|
{/* ... rest of card content */}
|
||||||
|
</div>
|
||||||
|
</Reorder.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key flag:** `dragListener={false}` on `Reorder.Item` disables the default "drag anywhere on the item" behavior, restricting drag to the handle only. This is the critical prop for handle-based reordering.
|
||||||
|
|
||||||
|
**Key flag:** `touch-none` Tailwind class on the handle prevents scroll interference on mobile (`touch-action: none`).
|
||||||
|
|
||||||
|
### Pattern 2: Fractional Indexing for sort_order
|
||||||
|
|
||||||
|
Fractional indexing avoids rewriting all rows on every drag. Only the moved item's `sort_order` changes.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Service function — reorderCandidates
|
||||||
|
// Computes new sort_order as midpoint between neighbors
|
||||||
|
export function reorderCandidates(
|
||||||
|
db: Db,
|
||||||
|
threadId: number,
|
||||||
|
orderedIds: number[],
|
||||||
|
): { success: boolean; error?: string } {
|
||||||
|
return db.transaction((tx) => {
|
||||||
|
// Verify thread is active
|
||||||
|
const thread = tx.select().from(threads).where(eq(threads.id, threadId)).get();
|
||||||
|
if (!thread || thread.status !== "active") {
|
||||||
|
return { success: false, error: "Thread not active" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch current sort_orders keyed by id
|
||||||
|
const rows = tx
|
||||||
|
.select({ id: threadCandidates.id, sortOrder: threadCandidates.sortOrder })
|
||||||
|
.from(threadCandidates)
|
||||||
|
.where(eq(threadCandidates.threadId, threadId))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const sortMap = new Map(rows.map((r) => [r.id, r.sortOrder]));
|
||||||
|
const sortedExisting = [...sortMap.entries()].sort((a, b) => a[1] - b[1]);
|
||||||
|
|
||||||
|
// Re-assign spaced values in the requested order
|
||||||
|
// (Simpler than midpoint for full reorder; midpoint for single-item moves is optimization)
|
||||||
|
orderedIds.forEach((id, index) => {
|
||||||
|
const newOrder = (index + 1) * 1000;
|
||||||
|
tx.update(threadCandidates)
|
||||||
|
.set({ sortOrder: newOrder })
|
||||||
|
.where(eq(threadCandidates.id, id))
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** The CONTEXT.md specifies midpoint-only for single-item moves. For the PATCH endpoint
|
||||||
|
receiving a full ordered list, re-spacing at 1000 intervals is simpler and still correct.
|
||||||
|
Midpoint optimization matters if the API receives only (id, position) for a single move —
|
||||||
|
confirm which approach the planner selects.
|
||||||
|
|
||||||
|
### Pattern 3: tempItems Flicker Prevention
|
||||||
|
|
||||||
|
React Query refetch after mutation causes a visible reorder "snap back" unless tempItems absorbs the transition.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In ThreadDetailPage:
|
||||||
|
const [tempItems, setTempItems] = useState<typeof thread.candidates | null>(null);
|
||||||
|
const displayItems = tempItems ?? thread.candidates; // server data already sorted by sort_order
|
||||||
|
|
||||||
|
const reorderMutation = useReorderCandidates(threadId);
|
||||||
|
|
||||||
|
function handleReorder(newOrder: typeof thread.candidates) {
|
||||||
|
setTempItems(newOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd() {
|
||||||
|
if (!tempItems) return;
|
||||||
|
reorderMutation.mutate(
|
||||||
|
{ orderedIds: tempItems.map((c) => c.id) },
|
||||||
|
{
|
||||||
|
onSettled: () => setTempItems(null), // clear after server confirms or fails
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 4: Drizzle Migration + Data Backfill
|
||||||
|
|
||||||
|
Migration must add column AND backfill existing rows with spaced values to avoid all-zero sort_order.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Migration SQL (generated by bun run db:generate):
|
||||||
|
ALTER TABLE `thread_candidates` ADD `sort_order` real NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Data backfill SQL (run as separate statement in migration or seed script):
|
||||||
|
-- SQLite window functions assign rank per thread, multiply by 1000
|
||||||
|
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
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**SQLite version note:** SQLite supports window functions since version 3.25.0 (2018). Bun
|
||||||
|
ships with a recent SQLite — this query is safe. Verify with `bun -e "import { Database } from 'bun:sqlite'; const db = new Database(':memory:'); console.log(db.query('SELECT sqlite_version()').get())"`.
|
||||||
|
|
||||||
|
### Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
- **Drag from anywhere on the card:** Without `dragListener={false}` on `Reorder.Item`, clicking the card to edit it triggers a drag. Always pair with `useDragControls`.
|
||||||
|
- **Ordering by integer with bulk update:** Updating all rows on every drag is O(n) writes. Use REAL (float) sort_order for midpoint single-update.
|
||||||
|
- **Storing order in the React Query cache only:** Sort order must persist to the server; local-only ordering is lost on page refresh.
|
||||||
|
- **Rendering `Reorder.Group` without `layout` on inner elements:** framer-motion needs `layout` prop on animated children to perform smooth gap animation. `Reorder.Item` handles this internally — do not nest another `motion.div` with conflicting layout props.
|
||||||
|
- **Missing `key` on Reorder.Item:** The key must be stable (candidate.id), not index — framer-motion uses it to track item identity across reorders.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Don't Hand-Roll
|
||||||
|
|
||||||
|
| Problem | Don't Build | Use Instead | Why |
|
||||||
|
|---------|-------------|-------------|-----|
|
||||||
|
| Drag-to-reorder list | Custom mousedown/mousemove/mouseup handlers | `framer-motion` Reorder.Group/Item | Handles pointer capture, scroll suppression, layout animation, keyboard fallback |
|
||||||
|
| Drag handle restriction | Event.stopPropagation tricks | `useDragControls` + `dragListener={false}` | Official framer-motion API; handles touch events correctly |
|
||||||
|
| Smooth gap animation during drag | CSS transform calculations | `Reorder.Item` layout animation | Built-in spring physics; other items animate to fill the gap automatically |
|
||||||
|
| Sort order persistence strategy | Custom complex state | Fractional indexing (REAL column, midpoint) | One write per drop; no full-list rewrite; proven pattern from Linear/Trello |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### Pitfall 1: All-Zero sort_order After Migration
|
||||||
|
**What goes wrong:** ALTERing the column with `DEFAULT 0` sets all existing rows to 0. `ORDER BY sort_order` returns them in arbitrary order.
|
||||||
|
**Why it happens:** SQLite sets new column values to the DEFAULT for existing rows.
|
||||||
|
**How to avoid:** Run the window-function UPDATE backfill as part of the migration or immediately after.
|
||||||
|
**Warning signs:** Candidates render in seemingly random or creation-id order after migration.
|
||||||
|
|
||||||
|
### Pitfall 2: Drag Initiates on Card Click
|
||||||
|
**What goes wrong:** User clicks to open the edit panel and the card starts dragging instead.
|
||||||
|
**Why it happens:** `Reorder.Item` defaults `dragListener={true}` — any pointer-down on the item starts dragging.
|
||||||
|
**How to avoid:** Set `dragListener={false}` on `Reorder.Item` and use `useDragControls` to start drag only from the grip handle's `onPointerDown`.
|
||||||
|
**Warning signs:** Click on candidate name opens drag instead of edit panel.
|
||||||
|
|
||||||
|
### Pitfall 3: React Query Flicker After Save
|
||||||
|
**What goes wrong:** After `reorderMutation` completes and React Query refetches, candidates visually snap back to server order for a frame.
|
||||||
|
**Why it happens:** React Query invalidates and refetches; server returns the new order but there's a brief moment where old cache is used.
|
||||||
|
**How to avoid:** Use `tempItems` local state pattern. Render `tempItems ?? thread.candidates`. Clear `tempItems` in `onSettled` (not `onSuccess`) so it covers both success and error cases.
|
||||||
|
**Warning signs:** Items visually "jump" after a drop.
|
||||||
|
|
||||||
|
### Pitfall 4: touch-none Missing on Drag Handle
|
||||||
|
**What goes wrong:** On mobile, dragging the grip handle scrolls the page instead of reordering.
|
||||||
|
**Why it happens:** Browser default: `touch-action` allows scroll on pointer-down.
|
||||||
|
**How to avoid:** Add `className="touch-none"` (Tailwind) or `style={{ touchAction: "none" }}` on the drag handle element.
|
||||||
|
**Warning signs:** Mobile drag scrolls page; items don't reorder on touch devices.
|
||||||
|
|
||||||
|
### Pitfall 5: Resolved Thread Reorder Accepted by API
|
||||||
|
**What goes wrong:** A resolved thread's candidates can be reordered if the server does not check thread status.
|
||||||
|
**Why it happens:** The API endpoint receives a valid payload and processes it without checking `thread.status`.
|
||||||
|
**How to avoid:** In `reorderCandidates()` service, verify `thread.status === "active"` and return error if not. Match pattern of `resolveThread()` which already does this check.
|
||||||
|
**Warning signs:** PATCH succeeds on a resolved thread; RANK-05 test fails.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Examples
|
||||||
|
|
||||||
|
### Zod Schema for Reorder Endpoint
|
||||||
|
```typescript
|
||||||
|
// src/shared/schemas.ts — add:
|
||||||
|
// Source: existing schema.ts patterns in project
|
||||||
|
export const reorderCandidatesSchema = z.object({
|
||||||
|
orderedIds: z.array(z.number().int().positive()).min(1),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shared Type
|
||||||
|
```typescript
|
||||||
|
// src/shared/types.ts — add:
|
||||||
|
export type ReorderCandidates = z.infer<typeof reorderCandidatesSchema>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Drizzle Schema Column
|
||||||
|
```typescript
|
||||||
|
// src/db/schema.ts — in threadCandidates table:
|
||||||
|
sortOrder: real("sort_order").notNull().default(0),
|
||||||
|
```
|
||||||
|
|
||||||
|
### getThreadWithCandidates ORDER BY Fix
|
||||||
|
```typescript
|
||||||
|
// src/server/services/thread.service.ts
|
||||||
|
// Change the candidateList query to order by sort_order:
|
||||||
|
.from(threadCandidates)
|
||||||
|
.innerJoin(categories, eq(threadCandidates.categoryId, categories.id))
|
||||||
|
.where(eq(threadCandidates.threadId, threadId))
|
||||||
|
.orderBy(threadCandidates.sortOrder) // add this
|
||||||
|
.all();
|
||||||
|
```
|
||||||
|
|
||||||
|
### createCandidate sort_order for New Candidates
|
||||||
|
```typescript
|
||||||
|
// src/server/services/thread.service.ts
|
||||||
|
// New candidates append to bottom — find current max and add 1000:
|
||||||
|
export function createCandidate(db, threadId, data) {
|
||||||
|
const maxRow = db
|
||||||
|
.select({ maxOrder: sql<number>`MAX(sort_order)` })
|
||||||
|
.from(threadCandidates)
|
||||||
|
.where(eq(threadCandidates.threadId, threadId))
|
||||||
|
.get();
|
||||||
|
const newSortOrder = (maxRow?.maxOrder ?? 0) + 1000;
|
||||||
|
|
||||||
|
return db.insert(threadCandidates).values({
|
||||||
|
...data,
|
||||||
|
sortOrder: newSortOrder,
|
||||||
|
}).returning().get();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hono PATCH Route
|
||||||
|
```typescript
|
||||||
|
// src/server/routes/threads.ts — add:
|
||||||
|
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 });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### useReorderCandidates Hook
|
||||||
|
```typescript
|
||||||
|
// src/client/hooks/useCandidates.ts — add:
|
||||||
|
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] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### RankBadge Component (inline)
|
||||||
|
```typescript
|
||||||
|
// Inline in CandidateListItem or extract as small component
|
||||||
|
const RANK_STYLES = [
|
||||||
|
{ color: "#D4AF37", label: "1st" }, // gold
|
||||||
|
{ color: "#C0C0C0", label: "2nd" }, // silver
|
||||||
|
{ color: "#CD7F32", label: "3rd" }, // bronze
|
||||||
|
];
|
||||||
|
|
||||||
|
function RankBadge({ rank }: { rank: number }) {
|
||||||
|
if (rank > 3) return null;
|
||||||
|
const { color } = RANK_STYLES[rank - 1];
|
||||||
|
return (
|
||||||
|
<LucideIcon
|
||||||
|
name="medal"
|
||||||
|
size={16}
|
||||||
|
className="shrink-0"
|
||||||
|
style={{ color }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### tests/helpers/db.ts: thread_candidates table update
|
||||||
|
```sql
|
||||||
|
-- Add to CREATE TABLE thread_candidates in tests/helpers/db.ts:
|
||||||
|
sort_order REAL NOT NULL DEFAULT 0,
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State of the Art
|
||||||
|
|
||||||
|
| Old Approach | Current Approach | When Changed | Impact |
|
||||||
|
|--------------|------------------|--------------|--------|
|
||||||
|
| `react-beautiful-dnd` | framer-motion Reorder | framer-motion v5+ Reorder API | Simpler API, same bundle already present, maintained by Framer |
|
||||||
|
| Integer sort_order with bulk UPDATE | REAL (float) fractional indexing | Best practice since ~2015 (Linear, Figma) | O(1) writes per drag vs O(n) |
|
||||||
|
| "Save order" button | Auto-save on drop | UX convention | Reduces friction; matches Trello/Linear behavior |
|
||||||
|
|
||||||
|
**Deprecated/outdated:**
|
||||||
|
- `react-beautiful-dnd`: No longer actively maintained; framer-motion Reorder is the modern replacement in React 18+ projects.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Full-list reorder vs single-item fractional update in PATCH body**
|
||||||
|
- What we know: CONTEXT.md says "only the moved item gets a single UPDATE (midpoint between neighbors)" but also says PATCH receives `orderedIds` array
|
||||||
|
- What's unclear: If the server receives the full ordered list, re-spacing at 1000-intervals is simpler than computing midpoints server-side
|
||||||
|
- Recommendation: Accept full `orderedIds` array in PATCH, re-space all at 1000-intervals; this is correct and simpler. Midpoint is only an optimization for very large lists (not relevant here).
|
||||||
|
|
||||||
|
2. **View toggle persistence scope**
|
||||||
|
- What we know: CONTEXT.md says use Zustand `candidateViewMode` for view toggle
|
||||||
|
- What's unclear: Whether to also persist in `localStorage` across page refreshes
|
||||||
|
- Recommendation: Zustand in-memory only (resets to list on refresh) is sufficient; no localStorage needed unless user reports preference loss as pain point.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Architecture
|
||||||
|
|
||||||
|
### Test Framework
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Framework | Bun test (built-in) |
|
||||||
|
| Config file | none — `bun test` auto-discovers `*.test.ts` |
|
||||||
|
| Quick run command | `bun test tests/services/thread.service.test.ts` |
|
||||||
|
| Full suite command | `bun test` |
|
||||||
|
|
||||||
|
### Phase Requirements → Test Map
|
||||||
|
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||||
|
|--------|----------|-----------|-------------------|-------------|
|
||||||
|
| RANK-01 | `reorderCandidates()` updates sort_order in DB in requested sequence | unit | `bun test tests/services/thread.service.test.ts` | ❌ Wave 0 (new test cases) |
|
||||||
|
| RANK-01 | `PATCH /api/threads/:id/candidates/reorder` returns 200 + reorders candidates | integration | `bun test tests/routes/threads.test.ts` | ❌ Wave 0 (new test cases) |
|
||||||
|
| RANK-02 | Rank badge rendering logic (index → medal color) | unit (component logic) | `bun test` | Manual-only — no component test infra |
|
||||||
|
| RANK-04 | `getThreadWithCandidates` returns candidates ordered by sort_order ascending | unit | `bun test tests/services/thread.service.test.ts` | ❌ Wave 0 |
|
||||||
|
| RANK-05 | `reorderCandidates()` returns error when thread is resolved | unit | `bun test tests/services/thread.service.test.ts` | ❌ Wave 0 |
|
||||||
|
| RANK-05 | `PATCH /api/threads/:id/candidates/reorder` returns 400 for resolved thread | integration | `bun test tests/routes/threads.test.ts` | ❌ Wave 0 |
|
||||||
|
|
||||||
|
### Sampling Rate
|
||||||
|
- **Per task commit:** `bun test tests/services/thread.service.test.ts`
|
||||||
|
- **Per wave merge:** `bun test`
|
||||||
|
- **Phase gate:** Full suite green before `/gsd:verify-work`
|
||||||
|
|
||||||
|
### Wave 0 Gaps
|
||||||
|
- [ ] New test cases in `tests/services/thread.service.test.ts` — covers RANK-01, RANK-04, RANK-05 service behavior
|
||||||
|
- [ ] New test cases in `tests/routes/threads.test.ts` — covers RANK-01, RANK-05 route behavior
|
||||||
|
- [ ] Update `tests/helpers/db.ts` CREATE TABLE for `thread_candidates` to add `sort_order REAL NOT NULL DEFAULT 0`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
### Primary (HIGH confidence)
|
||||||
|
- framer-motion `dist/types/index.d.ts` (v12.37.0 installed) — `Reorder.Group`, `Reorder.Item`, `useDragControls`, `dragListener` prop confirmed
|
||||||
|
- `src/client/lib/api.ts` — `apiPatch` confirmed available
|
||||||
|
- `src/client/lib/iconData.tsx` + lucide-react installed — `medal`, `grip-vertical`, `layout-list`, `layout-grid` icons confirmed via `bun -e` introspection
|
||||||
|
- `src/db/schema.ts` — current schema confirmed; `sort_order` column absent (needs migration)
|
||||||
|
- `tests/helpers/db.ts` — CREATE TABLE confirmed; needs `sort_order` column added
|
||||||
|
- `src/server/services/thread.service.ts` — `resolveThread()` pattern for status check reused in `reorderCandidates()`
|
||||||
|
- `.planning/phases/11-candidate-ranking/11-CONTEXT.md` — all locked decisions applied
|
||||||
|
|
||||||
|
### Secondary (MEDIUM confidence)
|
||||||
|
- framer-motion Reorder documentation patterns (consistent with installed type definitions)
|
||||||
|
- Fractional indexing / REAL sort_order pattern well-established in Linear, Trello, Figma implementations
|
||||||
|
|
||||||
|
### Tertiary (LOW confidence)
|
||||||
|
- None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Confidence breakdown:**
|
||||||
|
- Standard stack: HIGH — all libraries confirmed installed and API-verified from local node_modules
|
||||||
|
- Architecture: HIGH — patterns derived from existing codebase + confirmed framer-motion type signatures
|
||||||
|
- Pitfalls: HIGH — derived from direct API analysis (dragListener, touch-none) and known SQLite migration behavior
|
||||||
|
|
||||||
|
**Research date:** 2026-03-16
|
||||||
|
**Valid until:** 2026-04-16 (stable dependencies; framer-motion Reorder API is mature)
|
||||||
Reference in New Issue
Block a user