Files

25 KiB

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
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

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.

// 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>
// 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.

// 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.

// 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.

-- 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

// 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

// src/shared/types.ts — add:
export type ReorderCandidates = z.infer<typeof reorderCandidatesSchema>;

Drizzle Schema Column

// src/db/schema.ts — in threadCandidates table:
sortOrder: real("sort_order").notNull().default(0),

getThreadWithCandidates ORDER BY Fix

// 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

// 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

// 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

// 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)

// 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

-- 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.tsapiPatch 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.tsresolveThread() 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)