docs(11-candidate-ranking): create phase plan
This commit is contained in:
@@ -36,7 +36,7 @@
|
||||
|
||||
</details>
|
||||
|
||||
### 🚧 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 | - |
|
||||
|
||||
285
.planning/phases/11-candidate-ranking/11-01-PLAN.md
Normal file
285
.planning/phases/11-candidate-ranking/11-01-PLAN.md
Normal file
@@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/jlmak/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/11-candidate-ranking/11-CONTEXT.md
|
||||
@.planning/phases/11-candidate-ranking/11-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
||||
|
||||
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<typeof resolveThreadSchema>;
|
||||
// Add: export type ReorderCandidates = z.infer<typeof reorderCandidatesSchema>;
|
||||
```
|
||||
|
||||
From src/server/routes/threads.ts (route pattern):
|
||||
```typescript
|
||||
type Env = { Variables: { db?: any } };
|
||||
const app = new Hono<Env>();
|
||||
// Pattern: app.post("/:id/resolve", zValidator("json", resolveThreadSchema), (c) => { ... });
|
||||
```
|
||||
|
||||
From src/client/lib/api.ts:
|
||||
```typescript
|
||||
export async function apiPatch<T>(url: string, body: unknown): Promise<T>;
|
||||
```
|
||||
|
||||
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())
|
||||
)
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Schema, migration, service layer, and tests for sort_order + reorder</name>
|
||||
<files>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</files>
|
||||
<behavior>
|
||||
- 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
|
||||
</behavior>
|
||||
<action>
|
||||
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<typeof reorderCandidatesSchema>;
|
||||
```
|
||||
|
||||
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<number>` 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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bun test tests/services/thread.service.test.ts</automated>
|
||||
</verify>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: PATCH reorder route + route tests</name>
|
||||
<files>src/server/routes/threads.ts, tests/routes/threads.test.ts</files>
|
||||
<behavior>
|
||||
- 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)
|
||||
</behavior>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bun test tests/routes/threads.test.ts</automated>
|
||||
</verify>
|
||||
<done>PATCH /api/threads/:id/candidates/reorder returns 200 on active thread + persists order. Returns 400 on resolved thread. All existing route tests still pass.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
```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
|
||||
```
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/11-candidate-ranking/11-01-SUMMARY.md`
|
||||
</output>
|
||||
378
.planning/phases/11-candidate-ranking/11-02-PLAN.md
Normal file
378
.planning/phases/11-candidate-ranking/11-02-PLAN.md
Normal file
@@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/jlmak/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/11-candidate-ranking/11-CONTEXT.md
|
||||
@.planning/phases/11-candidate-ranking/11-RESEARCH.md
|
||||
@.planning/phases/11-candidate-ranking/11-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Interfaces created by Plan 01 that this plan depends on -->
|
||||
|
||||
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<typeof reorderCandidatesSchema>;
|
||||
```
|
||||
|
||||
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<T>(url: string, body: unknown): Promise<T>;
|
||||
```
|
||||
|
||||
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)
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: useReorderCandidates hook + uiStore view mode + CandidateListItem component</name>
|
||||
<files>src/client/hooks/useCandidates.ts, src/client/stores/uiStore.ts, src/client/components/CandidateListItem.tsx</files>
|
||||
<action>
|
||||
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 `<img src="/uploads/${imageFilename}" />` with object-cover. Else show `LucideIcon` of `categoryIcon` (size 20) in gray on gray-50 background.
|
||||
- NAME + BADGES: `flex-1 min-w-0` container.
|
||||
- Name: `text-sm font-semibold text-gray-900 truncate`
|
||||
- Badge row: `flex flex-wrap gap-1.5 mt-1` with weight (blue), price (green), category (gray + icon), StatusBadge, pros/cons badge (purple "+/- Notes").
|
||||
- Use same badge pill classes as CandidateCard.
|
||||
- ACTION BUTTONS (hover-reveal, right side): Winner (if isActive), Delete, External link (if productUrl). Use same click handlers as CandidateCard (openResolveDialog, openConfirmDeleteCandidate, openExternalLink from uiStore). Classes: `opacity-0 group-hover:opacity-100 transition-opacity` on a flex container.
|
||||
- Clicking the card body (not handle or action buttons) opens the edit panel: wrap in a clickable area that calls `openCandidateEditPanel(candidate.id)`.
|
||||
|
||||
- **RankBadge** (inline helper or small component in same file):
|
||||
```typescript
|
||||
const RANK_COLORS = ["#D4AF37", "#C0C0C0", "#CD7F32"]; // gold, silver, bronze
|
||||
function RankBadge({ rank }: { rank: number }) {
|
||||
if (rank > 3) return null;
|
||||
return <LucideIcon name="medal" size={16} className="shrink-0" style={{ color: RANK_COLORS[rank - 1] }} />;
|
||||
}
|
||||
```
|
||||
Export `RankBadge` so it can be reused by CandidateCard in grid view.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun run lint 2>&1 | head -30</automated>
|
||||
</verify>
|
||||
<done>CandidateListItem.tsx created with drag handle, rank badge, horizontal layout. useReorderCandidates hook created. uiStore has candidateViewMode. RankBadge exported. Lint passes.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Thread detail page with view toggle, Reorder.Group, rank badges in grid view</name>
|
||||
<files>src/client/routes/threads/$threadId.tsx, src/client/components/CandidateCard.tsx</files>
|
||||
<action>
|
||||
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 && <RankBadge rank={rank} />}` in the badge row (flex-wrap area), positioned as the first badge before weight/price.
|
||||
|
||||
2. **Thread detail page** (`src/client/routes/threads/$threadId.tsx`):
|
||||
- Import `{ Reorder }` from `framer-motion`.
|
||||
- Import `{ useState, useEffect }` from `react`.
|
||||
- Import `CandidateListItem` from `../../components/CandidateListItem`.
|
||||
- Import `useReorderCandidates` from `../../hooks/useCandidates`.
|
||||
- Import `useUIStore` selector for `candidateViewMode` and `setCandidateViewMode`.
|
||||
- Import `LucideIcon` (already imported).
|
||||
|
||||
- **View toggle** in the header area (after the "Add Candidate" button, or in the thread header row):
|
||||
- Two icon buttons: LayoutList and LayoutGrid (from Lucide).
|
||||
- Active button has `bg-gray-200 text-gray-900`, inactive has `text-gray-400 hover:text-gray-600`.
|
||||
- `onClick` calls `setCandidateViewMode("list")` or `setCandidateViewMode("grid")`.
|
||||
- Placed inline in a small toggle group: `flex items-center gap-1 bg-gray-100 rounded-lg p-0.5`
|
||||
|
||||
- **tempItems pattern** for flicker prevention:
|
||||
```typescript
|
||||
const [tempItems, setTempItems] = useState<typeof thread.candidates | null>(null);
|
||||
const displayItems = tempItems ?? thread.candidates;
|
||||
// thread.candidates is already sorted by sort_order from server (11-01)
|
||||
```
|
||||
Reset tempItems to null whenever `thread.candidates` reference changes (use useEffect if needed, or rely on onSettled clearing).
|
||||
|
||||
- **Reorder.Group** (list view, active threads only):
|
||||
- When `candidateViewMode === "list"` AND candidates exist:
|
||||
- If `isActive`: Wrap candidates in `<Reorder.Group axis="y" values={displayItems} onReorder={setTempItems} className="flex flex-col gap-2">`.
|
||||
- Each candidate renders `<CandidateListItem key={candidate.id} candidate={candidate} rank={index + 1} isActive={isActive} onStatusChange={...} />`.
|
||||
- On Reorder.Item `onDragEnd`, trigger the save. The save function:
|
||||
```typescript
|
||||
function handleDragEnd() {
|
||||
if (!tempItems) return;
|
||||
reorderMutation.mutate(
|
||||
{ orderedIds: tempItems.map((c) => c.id) },
|
||||
{ onSettled: () => setTempItems(null) }
|
||||
);
|
||||
}
|
||||
```
|
||||
Attach this to `Reorder.Group` via a wrapper that uses `onPointerUp` or pass as prop to `CandidateListItem`. The cleanest approach: use framer-motion's `onDragEnd` prop on each `Reorder.Item` — when any item finishes dragging, if tempItems differs from server data, fire the mutation.
|
||||
- If `!isActive` (resolved): Render the same `CandidateListItem` components but WITHOUT `Reorder.Group` — just a plain `<div className="flex flex-col gap-2">`. The `isActive={false}` prop hides drag handles. Rank badges remain visible per user decision.
|
||||
|
||||
- When `candidateViewMode === "grid"` AND candidates exist:
|
||||
- Render the existing `<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">` with `CandidateCard` components.
|
||||
- Pass `rank={index + 1}` to each CandidateCard so rank badges appear in grid view too.
|
||||
- Both active and resolved threads use static grid (no drag in grid view per user decision).
|
||||
|
||||
- **Important framer-motion detail**: `Reorder.Group` `values` must be the same array reference as what you iterate. Use `displayItems` for both `values` and `.map()`. The `Reorder.Item` `value` must be the same object reference (not a copy). Since we use the full candidate object, `value={candidate}` where candidate comes from `displayItems.map(...)`.
|
||||
|
||||
- **Empty state**: Keep the existing empty state rendering for both views.
|
||||
|
||||
- **useEffect to clear tempItems**: When `thread.candidates` changes (new data from server), clear tempItems:
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
setTempItems(null);
|
||||
}, [thread?.candidates]);
|
||||
```
|
||||
This ensures that when React Query refetches, tempItems is cleared and we render fresh server data.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun run lint 2>&1 | head -30</automated>
|
||||
</verify>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 3: Verify drag-to-reorder ranking experience</name>
|
||||
<files>none</files>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<what-built>Complete drag-to-reorder candidate ranking with list/grid view toggle, rank badges (gold/silver/bronze), auto-save persistence, and resolved thread guard.</what-built>
|
||||
<how-to-verify>
|
||||
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)
|
||||
</how-to-verify>
|
||||
<verify>Human confirms all 11 verification steps pass</verify>
|
||||
<done>All ranking features verified: drag reorder works, persists, shows rank badges in both views, disabled on resolved threads</done>
|
||||
<resume-signal>Type "approved" or describe any issues</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
```bash
|
||||
# Full test suite green
|
||||
bun test
|
||||
|
||||
# Verify all key files exist
|
||||
ls src/client/components/CandidateListItem.tsx
|
||||
grep -n "useReorderCandidates" src/client/hooks/useCandidates.ts
|
||||
grep -n "candidateViewMode" src/client/stores/uiStore.ts
|
||||
grep -n "Reorder.Group" src/client/routes/threads/\$threadId.tsx
|
||||
grep -n "RankBadge" src/client/components/CandidateCard.tsx
|
||||
|
||||
# Lint clean
|
||||
bun run lint
|
||||
```
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- List view shows horizontal cards with drag handles on active threads
|
||||
- Drag-to-reorder works via framer-motion Reorder.Group with grip handle
|
||||
- Order persists after page refresh via PATCH /api/threads/:id/candidates/reorder
|
||||
- tempItems pattern prevents React Query flicker
|
||||
- Top 3 candidates display gold (#D4AF37), silver (#C0C0C0), bronze (#CD7F32) medal badges
|
||||
- Rank badges visible in both list and grid views
|
||||
- Grid/list toggle works with list as default
|
||||
- Resolved threads: no drag handles, rank badges visible, static order
|
||||
- All tests pass, lint clean
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/11-candidate-ranking/11-02-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user