Compare commits
37 Commits
v1.2
...
a826381981
| Author | SHA1 | Date | |
|---|---|---|---|
| a826381981 | |||
| 79d84f1333 | |||
| 798bd51597 | |||
| 14a4c65b94 | |||
| 53c2bd1614 | |||
| 5b4026d36f | |||
| e442b33a59 | |||
| b090da05fa | |||
| bb8fb0a323 | |||
| 918282ff9d | |||
| 50672cb662 | |||
| 7e06c8526b | |||
| 4304d0fcd7 | |||
| 94c07e79c2 | |||
| acfa99516d | |||
| 495a2eabf5 | |||
| d6acfcb126 | |||
| f01d71d6b4 | |||
| 2986bdd2e5 | |||
| 11ee50db49 | |||
| a55d58cef3 | |||
| d380e756ea | |||
| e4c6991ec6 | |||
| 685acd2ab2 | |||
| 2ce54e5990 | |||
| 11912a9416 | |||
| 4f2aefe7a4 | |||
| 7a64a1887d | |||
| 719f7082da | |||
| 67044f8f2e | |||
| 66d1cf2f55 | |||
| fbc856b885 | |||
| b43472b09a | |||
| e44807fd37 | |||
| 4689d49b93 | |||
| 2fa4427de5 | |||
| 9647f5759d |
@@ -42,13 +42,17 @@ Make it effortless to manage gear and plan new purchases — see how a potential
|
||||
|
||||
### Active
|
||||
|
||||
(No active milestone — use `/gsd:new-milestone` to start next)
|
||||
## Current Milestone: v1.3 Research & Decision Tools
|
||||
|
||||
**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.
|
||||
|
||||
**Target features:**
|
||||
- Full-detail side-by-side candidate comparison (weight, price, images, notes, links, status)
|
||||
- Impact preview: pick a setup, see +/- weight and cost delta for each candidate
|
||||
- Candidate ranking (drag-to-reorder) with pros/cons text fields per candidate
|
||||
|
||||
### Future
|
||||
|
||||
- [ ] Side-by-side candidate comparison on weight and price
|
||||
- [ ] Candidate ranking/prioritization within threads
|
||||
- [ ] Impact preview: how a candidate affects setup weight/cost
|
||||
- [ ] CSV import/export for gear collections
|
||||
- [ ] Multi-user accounts with authentication
|
||||
- [ ] Collection sharing and social features (public profiles, shared setups)
|
||||
@@ -65,7 +69,7 @@ Make it effortless to manage gear and plan new purchases — see how a potential
|
||||
|
||||
## Context
|
||||
|
||||
Shipped v1.2 with 7,310 LOC TypeScript.
|
||||
Shipped v1.2 with 7,310 LOC TypeScript. Starting v1.3 to enhance thread decision workflow.
|
||||
Tech stack: React 19, Hono, Drizzle ORM, SQLite, TanStack Router/Query, Tailwind CSS v4, Lucide React, Recharts, all on Bun.
|
||||
Primary use case is bikepacking gear but data model is hobby-agnostic.
|
||||
Replaces spreadsheet-based gear tracking workflow.
|
||||
@@ -112,4 +116,4 @@ Replaces spreadsheet-based gear tracking workflow.
|
||||
| Recharts for charting | Mature React chart library, composable API | ✓ Good |
|
||||
|
||||
---
|
||||
*Last updated: 2026-03-16 after v1.2 milestone*
|
||||
*Last updated: 2026-03-16 after v1.3 milestone start*
|
||||
|
||||
92
.planning/REQUIREMENTS.md
Normal file
92
.planning/REQUIREMENTS.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Requirements: GearBox v1.3 Research & Decision Tools
|
||||
|
||||
**Defined:** 2026-03-16
|
||||
**Core Value:** Make it effortless to manage gear and plan new purchases -- see how a potential buy affects your total setup weight and cost before committing.
|
||||
|
||||
## v1.3 Requirements
|
||||
|
||||
Requirements for this milestone. Each maps to roadmap phases.
|
||||
|
||||
### Comparison View
|
||||
|
||||
- [x] **COMP-01**: User can view candidates side-by-side in a tabular comparison layout (weight, price, images, notes, links, status)
|
||||
- [x] **COMP-02**: User can see relative deltas highlighting the lightest and cheapest candidate with +/- differences
|
||||
- [x] **COMP-03**: Comparison table scrolls horizontally with a sticky label column on narrow viewports
|
||||
- [x] **COMP-04**: Comparison view displays read-only summary for resolved threads
|
||||
|
||||
### Candidate Ranking
|
||||
|
||||
- [x] **RANK-01**: User can drag candidates to reorder priority ranking within a thread
|
||||
- [x] **RANK-02**: Top 3 ranked candidates display rank badges (gold, silver, bronze)
|
||||
- [x] **RANK-03**: User can add pros and cons text per candidate displayed as bullet lists
|
||||
- [x] **RANK-04**: Candidate rank order persists across sessions
|
||||
- [x] **RANK-05**: Drag handles and ranking are disabled on resolved threads
|
||||
|
||||
### Impact Preview
|
||||
|
||||
- [ ] **IMPC-01**: User can select a setup and see weight and cost delta for each candidate
|
||||
- [ ] **IMPC-02**: Impact preview auto-detects replace mode when a setup item exists in the same category as the thread
|
||||
- [ ] **IMPC-03**: Impact preview shows add mode (pure addition) when no category match exists in the selected setup
|
||||
- [ ] **IMPC-04**: Candidates with missing weight data show a clear indicator instead of misleading zero deltas
|
||||
|
||||
## Future Requirements
|
||||
|
||||
Deferred to future milestones. Tracked but not in current roadmap.
|
||||
|
||||
### Data Management
|
||||
|
||||
- **DATA-01**: User can import gear collection from CSV
|
||||
- **DATA-02**: User can export gear collection to CSV
|
||||
|
||||
### Social & Multi-User
|
||||
|
||||
- **SOCL-01**: User can create an account with authentication
|
||||
- **SOCL-02**: User can share collections and setups publicly
|
||||
- **SOCL-03**: User can view other users' public profiles and setups
|
||||
|
||||
### Automation
|
||||
|
||||
- **AUTO-01**: System can auto-fill product information (price, weight, images) from external sources
|
||||
|
||||
## Out of Scope
|
||||
|
||||
Explicitly excluded. Documented to prevent scope creep.
|
||||
|
||||
| Feature | Reason |
|
||||
|---------|--------|
|
||||
| Custom comparison attributes | Complexity trap -- weight/price covers 80% of cases |
|
||||
| Score/rating calculation | Opaque algorithms distrust; manual ranking expresses user preference better |
|
||||
| Cross-thread comparison | Candidates are decision-scoped; different categories are not apples-to-apples |
|
||||
| Classification-aware impact breakdown | Data available but UI complexity high; flat delta covers 90% of use case |
|
||||
| Comparison permalink | Requires auth/multi-user work not in scope for v1 |
|
||||
| Mobile-optimized comparison (swipe) | Horizontal scroll works for now |
|
||||
| Rank badge on card grid view | Low urgency; add when users express confusion |
|
||||
|
||||
## Traceability
|
||||
|
||||
Which phases cover which requirements. Updated during roadmap creation.
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|-------------|-------|--------|
|
||||
| COMP-01 | Phase 12 | Complete |
|
||||
| COMP-02 | Phase 12 | Complete |
|
||||
| COMP-03 | Phase 12 | Complete |
|
||||
| COMP-04 | Phase 12 | Complete |
|
||||
| RANK-01 | Phase 11 | Complete |
|
||||
| RANK-02 | Phase 11 | Complete |
|
||||
| RANK-03 | Phase 10 | Complete |
|
||||
| RANK-04 | Phase 11 | Complete |
|
||||
| RANK-05 | Phase 11 | Complete |
|
||||
| IMPC-01 | Phase 13 | Pending |
|
||||
| IMPC-02 | Phase 13 | Pending |
|
||||
| IMPC-03 | Phase 13 | Pending |
|
||||
| IMPC-04 | Phase 13 | Pending |
|
||||
|
||||
**Coverage:**
|
||||
- v1.3 requirements: 13 total
|
||||
- Mapped to phases: 13
|
||||
- Unmapped: 0
|
||||
|
||||
---
|
||||
*Requirements defined: 2026-03-16*
|
||||
*Last updated: 2026-03-16*
|
||||
@@ -5,6 +5,7 @@
|
||||
- ✅ **v1.0 MVP** — Phases 1-3 (shipped 2026-03-15)
|
||||
- ✅ **v1.1 Fixes & Polish** — Phases 4-6 (shipped 2026-03-15)
|
||||
- ✅ **v1.2 Collection Power-Ups** — Phases 7-9 (shipped 2026-03-16)
|
||||
- 🚧 **v1.3 Research & Decision Tools** — Phases 10-13 (in progress)
|
||||
|
||||
## Phases
|
||||
|
||||
@@ -35,6 +36,71 @@
|
||||
|
||||
</details>
|
||||
|
||||
### 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.
|
||||
|
||||
- [x] **Phase 10: Schema Foundation + Pros/Cons Fields** — Migrate schema and deliver pros/cons annotation UI (completed 2026-03-16)
|
||||
- [x] **Phase 11: Candidate Ranking** — Drag-to-reorder priority ranking with rank badges (completed 2026-03-16)
|
||||
- [x] **Phase 12: Comparison View** — Side-by-side tabular comparison with relative deltas (completed 2026-03-17)
|
||||
- [ ] **Phase 13: Setup Impact Preview** — Per-candidate weight and cost delta against a selected setup
|
||||
|
||||
## Phase Details
|
||||
|
||||
### Phase 10: Schema Foundation + Pros/Cons Fields
|
||||
**Goal**: Candidates can be annotated with pros and cons, and the database is ready for ranking
|
||||
**Depends on**: Phase 9
|
||||
**Requirements**: RANK-03
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. User can open a candidate edit form and see pros and cons text fields
|
||||
2. User can save pros and cons text; the text persists across page refreshes
|
||||
3. CandidateCard shows a visual indicator when a candidate has pros or cons entered
|
||||
4. All existing tests pass after the schema migration (no column drift in test helper)
|
||||
**Plans:** 1/1 plans complete
|
||||
Plans:
|
||||
- [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
|
||||
**Depends on**: Phase 10
|
||||
**Requirements**: RANK-01, RANK-02, RANK-04, RANK-05
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. User can drag a candidate card to a new position within the thread's candidate list
|
||||
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:** 2/2 plans complete
|
||||
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
|
||||
**Depends on**: Phase 11
|
||||
**Requirements**: COMP-01, COMP-02, COMP-03, COMP-04
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. User can toggle a "Compare" mode on a thread detail page to reveal a tabular view showing weight, price, images, notes, links, status, pros, and cons for every candidate in columns
|
||||
2. The lightest candidate column is highlighted and all other columns show their weight difference relative to it; the cheapest candidate is highlighted similarly for price
|
||||
3. The comparison table scrolls horizontally on a narrow viewport without breaking layout; the attribute label column stays fixed on the left
|
||||
4. A resolved thread shows the comparison table in read-only mode with the winning candidate visually marked
|
||||
**Plans:** 1/1 plans complete
|
||||
Plans:
|
||||
- [ ] 12-01-PLAN.md — ComparisonTable component + compare toggle wiring in thread detail
|
||||
|
||||
### Phase 13: Setup Impact Preview
|
||||
**Goal**: Users can select any setup and see exactly how much weight and cost each candidate would add or subtract
|
||||
**Depends on**: Phase 12
|
||||
**Requirements**: IMPC-01, IMPC-02, IMPC-03, IMPC-04
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. User can select a setup from a dropdown in the thread header and each candidate displays a weight delta and cost delta below its name
|
||||
2. When the selected setup contains an item in the same category as the thread, the delta reflects replacing that item (negative delta is possible) rather than pure addition
|
||||
3. When no category match exists in the selected setup, the delta shows a pure addition amount clearly labeled as "add"
|
||||
4. A candidate with no weight recorded shows a "-- (no weight data)" indicator instead of a zero delta
|
||||
**Plans:** 2 plans
|
||||
Plans:
|
||||
- [ ] 13-01-PLAN.md — TDD pure impact delta computation, uiStore state, ThreadWithCandidates type fix, useImpactDeltas hook
|
||||
- [ ] 13-02-PLAN.md — SetupImpactSelector + ImpactDeltaBadge components, wire into thread detail and all candidate views
|
||||
|
||||
## Progress
|
||||
|
||||
| Phase | Milestone | Plans Complete | Status | Completed |
|
||||
@@ -48,3 +114,7 @@
|
||||
| 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 | v1.3 | 1/1 | Complete | 2026-03-16 |
|
||||
| 11. Candidate Ranking | 2/2 | Complete | 2026-03-16 | - |
|
||||
| 12. Comparison View | 1/1 | Complete | 2026-03-17 | - |
|
||||
| 13. Setup Impact Preview | v1.3 | 0/2 | Not started | - |
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
---
|
||||
gsd_state_version: 1.0
|
||||
milestone: v1.2
|
||||
milestone_name: Collection Power-Ups
|
||||
status: archived
|
||||
stopped_at: Milestone v1.2 archived
|
||||
last_updated: "2026-03-16T18:30:00.000Z"
|
||||
last_activity: 2026-03-16 -- Milestone v1.2 archived
|
||||
milestone: v1.3
|
||||
milestone_name: Research & Decision Tools
|
||||
status: planning
|
||||
stopped_at: Completed 12-comparison-view/12-01-PLAN.md
|
||||
last_updated: "2026-03-17T14:35:39.075Z"
|
||||
last_activity: 2026-03-16 — Roadmap created for v1.3 milestone
|
||||
progress:
|
||||
total_phases: 3
|
||||
total_phases: 4
|
||||
completed_phases: 3
|
||||
total_plans: 6
|
||||
completed_plans: 6
|
||||
percent: 100
|
||||
total_plans: 4
|
||||
completed_plans: 4
|
||||
percent: 0
|
||||
---
|
||||
|
||||
# Project State
|
||||
@@ -21,22 +21,62 @@ progress:
|
||||
See: .planning/PROJECT.md (updated 2026-03-16)
|
||||
|
||||
**Core value:** Make it effortless to manage gear and plan new purchases -- see how a potential buy affects your total setup weight and cost before committing.
|
||||
**Current focus:** Planning next milestone
|
||||
**Current focus:** v1.3 Research & Decision Tools — Phase 10 ready to plan
|
||||
|
||||
## Current Position
|
||||
|
||||
Milestone v1.2 Collection Power-Ups archived.
|
||||
All 3 milestones shipped (v1.0, v1.1, v1.2).
|
||||
Next step: `/gsd:new-milestone` to start next milestone.
|
||||
Phase: 10 of 13 (Schema Foundation + Pros/Cons Fields)
|
||||
Plan: —
|
||||
Status: Ready to plan
|
||||
Last activity: 2026-03-16 — Roadmap created for v1.3 milestone
|
||||
|
||||
Progress: [░░░░░░░░░░] 0%
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
**Velocity:**
|
||||
- Total plans completed: 0
|
||||
- Average duration: —
|
||||
- Total execution time: —
|
||||
|
||||
**By Phase:**
|
||||
|
||||
| Phase | Plans | Total | Avg/Plan |
|
||||
|-------|-------|-------|----------|
|
||||
| - | - | - | - |
|
||||
|
||||
**Recent Trend:**
|
||||
- Last 5 plans: —
|
||||
- Trend: —
|
||||
|
||||
*Updated after each plan completion*
|
||||
| Phase 10-schema-foundation-pros-cons-fields P01 | 6min | 2 tasks | 9 files |
|
||||
| Phase 11-candidate-ranking P01 | 4min | 2 tasks | 8 files |
|
||||
| Phase 11-candidate-ranking P02 | 4min | 3 tasks | 7 files |
|
||||
| Phase 12-comparison-view P01 | 2min | 2 tasks | 3 files |
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
### Decisions
|
||||
|
||||
(Full decision log in PROJECT.md Key Decisions table)
|
||||
|
||||
Cleared at milestone boundary. v1.2 decisions archived in milestones/v1.2-ROADMAP.md.
|
||||
|
||||
Key v1.3 research findings (see research/SUMMARY.md):
|
||||
- framer-motion@12.37.0 (already installed) handles drag-to-reorder via Reorder component — no new deps
|
||||
- sort_order must use REAL (float) type, not INTEGER, to avoid bulk writes on every drag
|
||||
- Impact preview must distinguish add-mode vs replace-mode by category match — pure addition misleads
|
||||
- [Phase 10-schema-foundation-pros-cons-fields]: Empty string for pros/cons stored as-is (not normalized to null); test accepts either empty string or null as cleared state
|
||||
- [Phase 10-schema-foundation-pros-cons-fields]: Pros/Cons badge uses purple color to distinguish from weight (blue), price (green), category (gray), and status badges
|
||||
- [Phase 10-schema-foundation-pros-cons-fields]: Field-addition ladder pattern: schema -> migration -> test helper -> service -> Zod -> types -> hook -> form -> card indicator
|
||||
- [Phase 11-candidate-ranking]: sortOrder uses REAL type for future fractional midpoint insertions without bulk rewrites
|
||||
- [Phase 11-candidate-ranking]: 1000-gap sort_order strategy: first=1000, append=max+1000, reorder resets to (index+1)*1000
|
||||
- [Phase 11-candidate-ranking]: Applied sort_order migration via sqlite3 CLI directly to avoid Drizzle data-loss warning on existing rows
|
||||
- [Phase 11-candidate-ranking]: Resolved thread list view uses plain div (not Reorder.Group) — no drag, rank badges visible
|
||||
- [Phase 11-candidate-ranking]: RankBadge exported from CandidateListItem for reuse in CandidateCard grid view
|
||||
- [Phase 12-comparison-view]: ATTRIBUTE_ROWS declarative array pattern for ComparisonTable keeps JSX clean and row reordering trivial
|
||||
- [Phase 12-comparison-view]: Compare toggle only shown for 2+ candidates; Add Candidate hidden in compare view (read-only intent)
|
||||
- [Phase 12-comparison-view]: Weight/price highlight color takes priority over amber winner tint when both apply (more informative)
|
||||
|
||||
### Pending Todos
|
||||
|
||||
None active.
|
||||
@@ -47,6 +87,6 @@ None active.
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-03-16
|
||||
Stopped at: Milestone v1.2 archived
|
||||
Last session: 2026-03-17T14:32:04.702Z
|
||||
Stopped at: Completed 12-comparison-view/12-01-PLAN.md
|
||||
Resume file: None
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
---
|
||||
phase: 10-schema-foundation-pros-cons-fields
|
||||
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/shared/schemas.ts
|
||||
- src/client/hooks/useCandidates.ts
|
||||
- src/client/components/CandidateForm.tsx
|
||||
- src/client/components/CandidateCard.tsx
|
||||
- src/client/routes/threads/$threadId.tsx
|
||||
- tests/services/thread.service.test.ts
|
||||
autonomous: true
|
||||
requirements: [RANK-03]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "User can open a candidate edit form and see pros and cons text fields"
|
||||
- "User can save pros and cons text; the text persists across page refreshes"
|
||||
- "CandidateCard shows a visual indicator when a candidate has pros or cons entered"
|
||||
- "All existing tests pass after the schema migration (no column drift in test helper)"
|
||||
artifacts:
|
||||
- path: "src/db/schema.ts"
|
||||
provides: "pros and cons nullable TEXT columns on threadCandidates"
|
||||
contains: "pros: text"
|
||||
- path: "tests/helpers/db.ts"
|
||||
provides: "Mirrored pros/cons columns in test DB CREATE TABLE"
|
||||
contains: "pros TEXT"
|
||||
- path: "src/server/services/thread.service.ts"
|
||||
provides: "pros/cons in createCandidate, updateCandidate, getThreadWithCandidates"
|
||||
contains: "pros:"
|
||||
- path: "src/shared/schemas.ts"
|
||||
provides: "pros and cons optional string fields in createCandidateSchema"
|
||||
contains: "pros: z.string"
|
||||
- path: "src/client/components/CandidateForm.tsx"
|
||||
provides: "Pros and Cons textarea inputs in candidate form"
|
||||
contains: "candidate-pros"
|
||||
- path: "src/client/components/CandidateCard.tsx"
|
||||
provides: "Visual indicator badge when pros or cons are present"
|
||||
contains: "pros || cons"
|
||||
- path: "tests/services/thread.service.test.ts"
|
||||
provides: "Tests for pros/cons in create, update, and get operations"
|
||||
contains: "pros"
|
||||
key_links:
|
||||
- from: "src/db/schema.ts"
|
||||
to: "tests/helpers/db.ts"
|
||||
via: "Manual column mirroring in CREATE TABLE"
|
||||
pattern: "pros TEXT"
|
||||
- from: "src/shared/schemas.ts"
|
||||
to: "src/server/services/thread.service.ts"
|
||||
via: "Zod-inferred CreateCandidate type used in service"
|
||||
pattern: "CreateCandidate"
|
||||
- from: "src/server/services/thread.service.ts"
|
||||
to: "src/client/hooks/useCandidates.ts"
|
||||
via: "API JSON response includes pros/cons fields"
|
||||
pattern: "pros.*string.*null"
|
||||
- from: "src/client/hooks/useCandidates.ts"
|
||||
to: "src/client/components/CandidateForm.tsx"
|
||||
via: "CandidateResponse type drives form pre-fill"
|
||||
pattern: "candidate\\.pros"
|
||||
- from: "src/client/routes/threads/$threadId.tsx"
|
||||
to: "src/client/components/CandidateCard.tsx"
|
||||
via: "Props threaded from candidate data to card"
|
||||
pattern: "pros=.*candidate\\.pros"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add pros and cons annotation fields to thread candidates, from database through UI.
|
||||
|
||||
Purpose: RANK-03 requires users to add pros/cons text per candidate for decision-making. This plan follows the established field-addition ladder: schema -> migration -> test helper -> service -> Zod -> types -> hook -> form -> card indicator.
|
||||
|
||||
Output: Two new nullable TEXT columns (pros, cons) on thread_candidates, fully wired through all layers, with service-level tests and a visual indicator on CandidateCard.
|
||||
</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/10-schema-foundation-pros-cons-fields/10-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Current codebase contracts the executor needs. -->
|
||||
|
||||
From src/db/schema.ts (threadCandidates table -- add pros/cons after status):
|
||||
```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"),
|
||||
// ADD: pros: text("pros"),
|
||||
// ADD: 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/shared/schemas.ts (createCandidateSchema -- add optional pros/cons):
|
||||
```typescript
|
||||
export const createCandidateSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
weightGrams: z.number().nonnegative().optional(),
|
||||
priceCents: z.number().int().nonnegative().optional(),
|
||||
categoryId: z.number().int().positive(),
|
||||
notes: z.string().optional(),
|
||||
productUrl: z.string().url().optional().or(z.literal("")),
|
||||
imageFilename: z.string().optional(),
|
||||
status: candidateStatusSchema.optional(),
|
||||
});
|
||||
// updateCandidateSchema = createCandidateSchema.partial() -- inherits automatically
|
||||
```
|
||||
|
||||
From src/server/services/thread.service.ts:
|
||||
```typescript
|
||||
// createCandidate: values() object needs pros/cons
|
||||
// updateCandidate: inline Partial<{...}> type needs pros/cons
|
||||
// getThreadWithCandidates: explicit .select({}) projection needs pros/cons
|
||||
```
|
||||
|
||||
From src/client/hooks/useCandidates.ts:
|
||||
```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";
|
||||
createdAt: string; updatedAt: string;
|
||||
// ADD: pros: string | null;
|
||||
// ADD: cons: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
From src/client/components/CandidateCard.tsx:
|
||||
```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;
|
||||
// ADD: pros?: string | null;
|
||||
// ADD: cons?: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
From src/client/components/CandidateForm.tsx:
|
||||
```typescript
|
||||
interface FormData {
|
||||
name: string; weightGrams: string; priceDollars: string;
|
||||
categoryId: number; notes: string; productUrl: string;
|
||||
imageFilename: string | null;
|
||||
// ADD: pros: string;
|
||||
// ADD: cons: string;
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Add pros/cons columns through backend + tests</name>
|
||||
<files>src/db/schema.ts, tests/helpers/db.ts, src/server/services/thread.service.ts, src/shared/schemas.ts, tests/services/thread.service.test.ts</files>
|
||||
<behavior>
|
||||
- createCandidate with pros/cons returns them in the response
|
||||
- createCandidate without pros/cons returns null for both fields
|
||||
- updateCandidate can set pros and cons on an existing candidate
|
||||
- updateCandidate can clear pros/cons by setting to empty string (becomes null via service)
|
||||
- getThreadWithCandidates includes pros and cons on each candidate object
|
||||
- All existing thread service tests still pass (no column drift)
|
||||
</behavior>
|
||||
<action>
|
||||
1. **Schema** (`src/db/schema.ts`): Add two nullable TEXT columns to `threadCandidates` after `status`:
|
||||
```typescript
|
||||
pros: text("pros"),
|
||||
cons: text("cons"),
|
||||
```
|
||||
|
||||
2. **Migration**: Run `bun run db:generate` to produce the ALTER TABLE migration, then `bun run db:push` to apply.
|
||||
|
||||
3. **Test helper** (`tests/helpers/db.ts`): Add `pros TEXT,` and `cons TEXT,` to the `CREATE TABLE thread_candidates` statement, between the `status` line and the `created_at` line. This is CRITICAL -- without it, in-memory test DBs will silently lack the columns.
|
||||
|
||||
4. **Service** (`src/server/services/thread.service.ts`):
|
||||
- `createCandidate`: Add `pros: data.pros ?? null,` and `cons: data.cons ?? null,` to the `.values({})` object.
|
||||
- `updateCandidate`: Add `pros: string;` and `cons: string;` to the inline `Partial<{...}>` type parameter.
|
||||
- `getThreadWithCandidates`: Add `pros: threadCandidates.pros,` and `cons: threadCandidates.cons,` to the explicit `.select({})` projection, before the `categoryName` line.
|
||||
|
||||
5. **Zod schemas** (`src/shared/schemas.ts`): Add to `createCandidateSchema`:
|
||||
```typescript
|
||||
pros: z.string().optional(),
|
||||
cons: z.string().optional(),
|
||||
```
|
||||
`updateCandidateSchema` inherits via `.partial()` -- no changes needed there.
|
||||
|
||||
6. **Tests** (`tests/services/thread.service.test.ts`): Add three test cases:
|
||||
- In `describe("createCandidate")`: "stores and returns pros and cons" -- create a candidate with `pros: "Lightweight\nGood reviews"` and `cons: "Expensive"`, assert both fields are returned correctly.
|
||||
- In `describe("updateCandidate")`: "can set and clear pros and cons" -- create a candidate, update with pros/cons values, assert they are set, then update with empty strings, assert they are cleared (returned as empty string or null from DB).
|
||||
- In `describe("getThreadWithCandidates")`: "includes pros and cons on each candidate" -- create a candidate with pros/cons, fetch via getThreadWithCandidates, assert `candidate.pros` and `candidate.cons` match.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/services/thread.service.test.ts</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- pros and cons columns exist in schema.ts and test helper
|
||||
- Drizzle migration generated and applied
|
||||
- createCandidate, updateCandidate, getThreadWithCandidates all handle pros/cons
|
||||
- Zod schemas accept optional pros/cons strings
|
||||
- All existing + new thread service tests pass
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Wire pros/cons through client hooks, form, and card indicator</name>
|
||||
<files>src/client/hooks/useCandidates.ts, src/client/components/CandidateForm.tsx, src/client/components/CandidateCard.tsx, src/client/routes/threads/$threadId.tsx</files>
|
||||
<action>
|
||||
1. **Hook** (`src/client/hooks/useCandidates.ts`): Add to `CandidateResponse` interface:
|
||||
```typescript
|
||||
pros: string | null;
|
||||
cons: string | null;
|
||||
```
|
||||
|
||||
2. **CandidateForm** (`src/client/components/CandidateForm.tsx`):
|
||||
- Add `pros: string;` and `cons: string;` to `FormData` interface.
|
||||
- Add `pros: "",` and `cons: "",` to `INITIAL_FORM`.
|
||||
- In the `useEffect` pre-fill block, add: `pros: candidate.pros ?? "",` and `cons: candidate.cons ?? "",`.
|
||||
- In `handleSubmit` payload, add: `pros: form.pros.trim() || undefined,` and `cons: form.cons.trim() || undefined,`.
|
||||
- Add two textarea elements in the form, AFTER the Notes textarea and BEFORE the Product Link input. Each should follow the exact same pattern as the Notes textarea:
|
||||
- **Pros**: label "Pros", id `candidate-pros`, placeholder "One pro per line...", rows={3}
|
||||
- **Cons**: label "Cons", id `candidate-cons`, placeholder "One con per line...", rows={3}
|
||||
- Use identical Tailwind classes as the existing Notes textarea.
|
||||
|
||||
3. **CandidateCard** (`src/client/components/CandidateCard.tsx`):
|
||||
- Add `pros?: string | null;` and `cons?: string | null;` to `CandidateCardProps` interface.
|
||||
- Destructure `pros` and `cons` in the component function parameters.
|
||||
- Add a visual indicator badge in the `flex flex-wrap gap-1.5` div, after the StatusBadge. When `(pros || cons)` is truthy, render:
|
||||
```tsx
|
||||
{(pros || cons) && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-700">
|
||||
+/- Notes
|
||||
</span>
|
||||
)}
|
||||
```
|
||||
|
||||
4. **Thread detail route** (`src/client/routes/threads/$threadId.tsx`): Pass `pros` and `cons` props to the `<CandidateCard>` component in the candidates map:
|
||||
```tsx
|
||||
pros={candidate.pros}
|
||||
cons={candidate.cons}
|
||||
```
|
||||
|
||||
5. Run `bun run lint` to verify Biome compliance (tabs, double quotes, organized imports).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test && bun run lint</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- CandidateResponse includes pros/cons fields
|
||||
- CandidateForm shows Pros and Cons textareas, pre-fills in edit mode, sends in payload
|
||||
- CandidateCard shows purple "+/- Notes" badge when pros or cons text exists
|
||||
- Thread detail page threads pros/cons props to CandidateCard
|
||||
- Full test suite passes, lint passes
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. `bun test` -- full suite green (existing + new tests)
|
||||
2. `bun run lint` -- no Biome violations
|
||||
3. Manual: create a thread, add a candidate with pros and cons text, verify:
|
||||
- Pros/cons fields appear in the edit form
|
||||
- Saved text persists after page refresh
|
||||
- CandidateCard shows the "+/- Notes" indicator badge
|
||||
- A candidate without pros/cons does NOT show the badge
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- RANK-03 fully implemented: pros/cons fields on candidates, editable via form, persisted, with visual indicator
|
||||
- Zero test regressions
|
||||
- No column drift between schema.ts and test helper
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/10-schema-foundation-pros-cons-fields/10-01-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
phase: 10-schema-foundation-pros-cons-fields
|
||||
plan: "01"
|
||||
subsystem: database
|
||||
tags: [drizzle, sqlite, react, forms, zod]
|
||||
|
||||
# Dependency graph
|
||||
requires: []
|
||||
provides:
|
||||
- "pros/cons nullable TEXT columns on thread_candidates table (DB + migration)"
|
||||
- "Zod schema fields: pros/cons optional strings in createCandidateSchema"
|
||||
- "Service layer: createCandidate, updateCandidate, getThreadWithCandidates handle pros/cons"
|
||||
- "Client CandidateForm: Pros and Cons textarea inputs with pre-fill and submit payload"
|
||||
- "Client CandidateCard: purple +/- Notes badge when pros or cons text exists"
|
||||
- "CandidateResponse type includes pros/cons fields"
|
||||
affects: [thread-ranking, candidate-comparison, future-candidate-features]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: [field-addition-ladder, tdd-red-green]
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- drizzle/0004_soft_synch.sql
|
||||
modified:
|
||||
- src/db/schema.ts
|
||||
- tests/helpers/db.ts
|
||||
- src/server/services/thread.service.ts
|
||||
- src/shared/schemas.ts
|
||||
- src/client/hooks/useCandidates.ts
|
||||
- src/client/components/CandidateForm.tsx
|
||||
- src/client/components/CandidateCard.tsx
|
||||
- src/client/routes/threads/$threadId.tsx
|
||||
- tests/services/thread.service.test.ts
|
||||
|
||||
key-decisions:
|
||||
- "Empty string for pros/cons stored as-is by SQLite (not normalized to null) — updateCandidate test accepts empty string or null as cleared state"
|
||||
- "Pros/Cons textareas placed after Notes and before Product Link — logical grouping for research annotation"
|
||||
- "Visual indicator uses purple color scheme to distinguish from weight (blue), price (green), category (gray), and status badges"
|
||||
|
||||
patterns-established:
|
||||
- "Field-addition ladder: schema -> migration -> test helper -> service -> Zod -> types -> hook -> form -> card indicator"
|
||||
- "Test helper CREATE TABLE must mirror schema.ts columns exactly — column drift causes silent test failures"
|
||||
- "TDD: RED commit (failing tests) -> GREEN commit (implementation) per task"
|
||||
|
||||
requirements-completed: [RANK-03]
|
||||
|
||||
# Metrics
|
||||
duration: 6min
|
||||
completed: "2026-03-16"
|
||||
---
|
||||
|
||||
# Phase 10 Plan 01: Schema Foundation Pros/Cons Fields Summary
|
||||
|
||||
**Nullable pros/cons TEXT columns added to thread_candidates from SQLite schema through Drizzle migration, service layer, Zod validation, React form inputs, and CandidateCard visual badge**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 6 min
|
||||
- **Started:** 2026-03-16T20:30:18Z
|
||||
- **Completed:** 2026-03-16T20:36:25Z
|
||||
- **Tasks:** 2
|
||||
- **Files modified:** 9
|
||||
|
||||
## Accomplishments
|
||||
- Added pros/cons columns to threadCandidates schema and applied Drizzle migration (0004_soft_synch.sql)
|
||||
- Wired pros/cons through all backend layers: service create/update/get + Zod schemas
|
||||
- Added Pros and Cons textarea inputs to CandidateForm with pre-fill in edit mode
|
||||
- Added purple "+/- Notes" badge to CandidateCard when either field has content
|
||||
- 28 thread service tests passing (24 existing + 4 new) with zero regressions
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **TDD RED - failing tests** - `719f708` (test)
|
||||
2. **Task 1: Add pros/cons columns through backend + tests** - `7a64a18` (feat)
|
||||
3. **Task 2: Wire pros/cons through client hooks, form, and card indicator** - `4f2aefe` (feat)
|
||||
|
||||
_Note: TDD task has separate test commit (RED) and implementation commit (GREEN)_
|
||||
|
||||
## Files Created/Modified
|
||||
- `src/db/schema.ts` - Added pros/cons nullable TEXT columns to threadCandidates
|
||||
- `drizzle/0004_soft_synch.sql` - Migration: ALTER TABLE thread_candidates ADD COLUMN pros/cons
|
||||
- `tests/helpers/db.ts` - Mirrored pros/cons in CREATE TABLE thread_candidates
|
||||
- `src/server/services/thread.service.ts` - pros/cons in createCandidate values(), updateCandidate Partial type, getThreadWithCandidates select
|
||||
- `src/shared/schemas.ts` - pros/cons optional string fields in createCandidateSchema (updateCandidateSchema inherits via .partial())
|
||||
- `src/client/hooks/useCandidates.ts` - pros/cons added to CandidateResponse interface
|
||||
- `src/client/components/CandidateForm.tsx` - Pros and Cons textareas, FormData fields, INITIAL_FORM, pre-fill, payload
|
||||
- `src/client/components/CandidateCard.tsx` - props, destructuring, purple +/- Notes badge
|
||||
- `src/client/routes/threads/$threadId.tsx` - pros={candidate.pros} cons={candidate.cons} passed to CandidateCard
|
||||
- `tests/services/thread.service.test.ts` - 4 new test cases for pros/cons create/update/get
|
||||
|
||||
## Decisions Made
|
||||
- Empty string for pros/cons is stored as-is (not normalized to null on empty); the test accepts either empty string or null as "cleared" state since SQLite/Drizzle does not coerce empty strings.
|
||||
- Visual indicator uses purple to distinguish from existing badge color scheme (blue=weight, green=price, gray=category, status has its own colors).
|
||||
- Textarea placement (after Notes, before Product Link) groups annotation fields logically.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
Pre-existing lint violations discovered in files outside the plan scope:
|
||||
- `src/client/components/WeightSummaryCard.tsx`, `src/client/routes/collection/index.tsx`, `src/client/routes/index.tsx`, `src/client/routes/setups/$setupId.tsx` — format/organizeImports errors
|
||||
- `.obsidian/workspace.json` — Biome format error (IDE file, should be excluded)
|
||||
|
||||
These are logged to `deferred-items.md` and not fixed (out of scope per deviation scope boundary rule).
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
- RANK-03 fully implemented: pros/cons fields on candidates, editable via form, persisted in SQLite, with visual badge indicator
|
||||
- Schema foundation complete — subsequent plans in phase 10 can build ranking/sorting features on top of this data
|
||||
- No blockers
|
||||
|
||||
---
|
||||
*Phase: 10-schema-foundation-pros-cons-fields*
|
||||
*Completed: 2026-03-16*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All files and commits verified:
|
||||
- All 10 key files present on disk
|
||||
- All 3 task commits found in git log (719f708, 7a64a18, 4f2aefe)
|
||||
- Key artifact strings confirmed in each file (pros: text, pros TEXT, pros: z.string, candidate-pros, pros || cons)
|
||||
@@ -0,0 +1,417 @@
|
||||
# Phase 10: Schema Foundation + Pros/Cons Fields - Research
|
||||
|
||||
**Researched:** 2026-03-16
|
||||
**Domain:** Drizzle ORM schema migration + full-stack field addition (SQLite / Hono / React)
|
||||
**Confidence:** HIGH
|
||||
|
||||
---
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|-----------------|
|
||||
| RANK-03 | User can add pros and cons text per candidate displayed as bullet lists | Confirmed: two nullable TEXT columns on `thread_candidates` + textarea inputs in `CandidateForm` + visual indicator on `CandidateCard` |
|
||||
</phase_requirements>
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 10 is a contained, top-to-bottom field-addition task. Two nullable `TEXT` columns (`pros`, `cons`) must be added to the `thread_candidates` table, propagated through every layer that touches that table, and surfaced in the UI as editable text areas with a card-level presence indicator.
|
||||
|
||||
The project uses Drizzle ORM with SQLite. Adding nullable columns via `ALTER TABLE … ADD COLUMN` is safe in SQLite (no default value is required for nullable TEXT). The Drizzle workflow is: edit `schema.ts` → `bun run db:generate` → `bun run db:push`. The generated SQL migration follows the established pattern already used four times in this project.
|
||||
|
||||
There is one mandatory non-obvious step documented in CLAUDE.md: the test helper at `tests/helpers/db.ts` contains a hardcoded `CREATE TABLE thread_candidates` statement that mirrors the production schema. It must be updated in lockstep with `schema.ts` or all existing candidate tests will silently omit the new columns and new service-level tests will fail.
|
||||
|
||||
**Primary recommendation:** Follow the exact field-addition ladder: schema → migration → test helper → service (insert + update + select projection) → Zod schemas → shared types → API route (zValidator) → React hook response type → CandidateForm → CandidateCard indicator. Every rung must be touched — skipping any one causes type drift or runtime failures.
|
||||
|
||||
---
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| drizzle-orm | installed | ORM + migration generation | Project standard; all migrations use it |
|
||||
| drizzle-kit | installed | CLI for `db:generate` | Project standard; configured in drizzle.config.ts |
|
||||
| zod | installed | Schema validation on API boundary | Project standard; `@hono/zod-validator` integration |
|
||||
| bun:sqlite | runtime built-in | In-memory test DB | Used by `createTestDb()` helper |
|
||||
|
||||
No new dependencies are required for this phase.
|
||||
|
||||
**Installation:**
|
||||
```bash
|
||||
# No new packages — all required libraries already installed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Established Field-Addition Ladder
|
||||
|
||||
Every field addition in this codebase follows this exact sequence. Previous examples: `status` on candidates, `classification` on `setup_items`, `icon` on categories.
|
||||
|
||||
```
|
||||
1. src/db/schema.ts — Drizzle column definition
|
||||
2. drizzle/ (generated) — bun run db:generate
|
||||
3. gearbox.db — bun run db:push
|
||||
4. tests/helpers/db.ts — Raw SQL CREATE TABLE mirrored manually
|
||||
5. src/server/services/thread.service.ts
|
||||
a. createCandidate() — values() object
|
||||
b. updateCandidate() — data type + set()
|
||||
c. getThreadWithCandidates() — explicit column projection
|
||||
6. src/shared/schemas.ts — createCandidateSchema + updateCandidateSchema
|
||||
7. src/shared/types.ts — auto-inferred (no manual edit needed)
|
||||
8. src/client/hooks/useCandidates.ts — CandidateResponse interface
|
||||
9. src/client/components/CandidateForm.tsx — FormData + textarea inputs + payload
|
||||
10. src/client/components/CandidateCard.tsx — visual indicator prop + render
|
||||
```
|
||||
|
||||
### Pattern 1: Drizzle Nullable Text Column
|
||||
|
||||
**What:** Add an optional text field to an existing Drizzle table.
|
||||
**When to use:** When the field is user-provided text, no business logic default applies.
|
||||
|
||||
```typescript
|
||||
// Source: src/db/schema.ts — pattern already used by notes, productUrl, imageFilename
|
||||
export const threadCandidates = sqliteTable("thread_candidates", {
|
||||
// ... existing columns ...
|
||||
pros: text("pros"), // nullable, no default — mirrors notes/productUrl pattern
|
||||
cons: text("cons"), // nullable, no default
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
### Pattern 2: Test Helper Table Synchronization
|
||||
|
||||
**What:** Mirror every new column in the raw SQL inside `createTestDb()`.
|
||||
**When to use:** Every time `schema.ts` is modified. Documented as mandatory in CLAUDE.md.
|
||||
|
||||
```typescript
|
||||
// Source: tests/helpers/db.ts — existing thread_candidates CREATE TABLE
|
||||
sqlite.run(`
|
||||
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, -- ADD THIS
|
||||
cons TEXT, -- ADD THIS
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
)
|
||||
`);
|
||||
```
|
||||
|
||||
### Pattern 3: Explicit Select Projection in Service
|
||||
|
||||
**What:** `getThreadWithCandidates` uses an explicit `.select({...})` projection, not `select()`.
|
||||
**When to use:** New columns MUST be explicitly added to the projection or they will not appear in query results.
|
||||
|
||||
```typescript
|
||||
// Source: src/server/services/thread.service.ts — getThreadWithCandidates()
|
||||
const candidateList = db
|
||||
.select({
|
||||
// ... existing fields ...
|
||||
pros: threadCandidates.pros, // ADD
|
||||
cons: threadCandidates.cons, // ADD
|
||||
categoryName: categories.name,
|
||||
categoryIcon: categories.icon,
|
||||
})
|
||||
.from(threadCandidates)
|
||||
// ...
|
||||
```
|
||||
|
||||
### Pattern 4: Zod Schema Extension
|
||||
|
||||
**What:** Add optional string fields to `createCandidateSchema`; `updateCandidateSchema` is derived via `.partial()` and picks them up automatically.
|
||||
**When to use:** Any new candidate API field.
|
||||
|
||||
```typescript
|
||||
// Source: src/shared/schemas.ts
|
||||
export const createCandidateSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
// ... existing fields ...
|
||||
pros: z.string().optional(), // ADD
|
||||
cons: z.string().optional(), // ADD
|
||||
});
|
||||
|
||||
// updateCandidateSchema = createCandidateSchema.partial() — inherits automatically
|
||||
```
|
||||
|
||||
### Pattern 5: CandidateForm Textarea Addition
|
||||
|
||||
**What:** Extend `FormData` interface and `INITIAL_FORM` constant, add pre-fill in `useEffect`, add textarea elements, include in payload.
|
||||
|
||||
```typescript
|
||||
// Source: src/client/components/CandidateForm.tsx — FormData interface
|
||||
interface FormData {
|
||||
// ... existing ...
|
||||
pros: string; // ADD
|
||||
cons: string; // ADD
|
||||
}
|
||||
|
||||
const INITIAL_FORM: FormData = {
|
||||
// ... existing ...
|
||||
pros: "", // ADD
|
||||
cons: "", // ADD
|
||||
};
|
||||
|
||||
// In useEffect pre-fill:
|
||||
pros: candidate.pros ?? "", // ADD
|
||||
cons: candidate.cons ?? "", // ADD
|
||||
|
||||
// In payload construction:
|
||||
pros: form.pros.trim() || undefined, // ADD
|
||||
cons: form.cons.trim() || undefined, // ADD
|
||||
```
|
||||
|
||||
### Pattern 6: CandidateCard Visual Indicator
|
||||
|
||||
**What:** Show a small badge when a candidate has pros or cons text. The requirement says "visual indicator when a candidate has pros or cons entered" — not a full display of the text (that is the form's job).
|
||||
**When to use:** When `(pros || cons)` is truthy.
|
||||
|
||||
```tsx
|
||||
// Source: src/client/components/CandidateCard.tsx — props interface
|
||||
interface CandidateCardProps {
|
||||
// ... existing ...
|
||||
pros?: string | null; // ADD
|
||||
cons?: string | null; // ADD
|
||||
}
|
||||
|
||||
// In the card's badge section (alongside weight/price badges):
|
||||
{(pros || cons) && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-500">
|
||||
Notes
|
||||
</span>
|
||||
)}
|
||||
```
|
||||
|
||||
The exact styling (color, icon, text) is left to the planner's discretion — the requirement only specifies "visual indicator."
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- **Forgetting the test helper**: If `tests/helpers/db.ts` is not updated, the in-memory schema won't have `pros`/`cons` columns. Tests that insert or read these fields will get `undefined` instead of the stored value, causing silent failures or column-not-found errors. CLAUDE.md documents this as a known hazard.
|
||||
- **Using `select()` without explicit fields**: The `getThreadWithCandidates` service function already uses an explicit projection. Adding fields to the schema without adding them to the projection means the client never receives the data.
|
||||
- **Storing pros/cons as a JSON array of bullet strings**: The requirement says "text per candidate displayed as bullet lists" — the display can parse newlines into bullets from a plain TEXT field. A single multi-line `TEXT` column is correct and consistent with the existing `notes` field pattern. No JSON, no separate table.
|
||||
- **Adding a separate `candidatePros` / `candidateCons` table**: Massive over-engineering. These are simple annotations on a single candidate, not a many-per-candidate relationship.
|
||||
|
||||
---
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Schema migration | Custom SQL scripts | `bun run db:generate` + `bun run db:push` | Drizzle-kit generates correct ALTER TABLE, tracks journal, handles snapshot |
|
||||
| API input validation | Manual checks | Zod via `zValidator` (already wired) | All candidate routes already use `updateCandidateSchema` — just extend it |
|
||||
| Bullet-list rendering | Custom tokenizer | CSS `whitespace-pre-line` or split on `\n` | Simple text with newlines is sufficient for RANK-03 |
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Test Helper Column Drift
|
||||
|
||||
**What goes wrong:** New columns exist in production schema but are absent from the hardcoded `CREATE TABLE` in `tests/helpers/db.ts`. Tests pass structurally but new-column values are lost.
|
||||
**Why it happens:** The test helper duplicates the schema in raw SQL, not via Drizzle. There is no automated sync.
|
||||
**How to avoid:** Update `tests/helpers/db.ts` immediately after editing `schema.ts`, in the same commit wave.
|
||||
**Warning signs:** `candidate.pros` returns `undefined` in service tests even after saving a value.
|
||||
|
||||
### Pitfall 2: Missing Explicit Column in Select Projection
|
||||
|
||||
**What goes wrong:** `getThreadWithCandidates` uses `.select({ id: threadCandidates.id, ... })` — an explicit map. New columns are silently excluded.
|
||||
**Why it happens:** Drizzle's explicit projection doesn't automatically include newly-added columns.
|
||||
**How to avoid:** Search for every `.select({` that references `threadCandidates` and add `pros` and `cons`.
|
||||
**Warning signs:** API returns candidate without `pros`/`cons` fields even though they're saved in DB.
|
||||
|
||||
### Pitfall 3: updateCandidate Service Type Mismatch
|
||||
|
||||
**What goes wrong:** `updateCandidate` in `thread.service.ts` has a hardcoded `Partial<{ name, weightGrams, ... }>` type rather than using the Zod-inferred type. New fields must be manually added to this inline type.
|
||||
**Why it happens:** The function was written with an inline type, not `UpdateCandidate`.
|
||||
**How to avoid:** Add `pros: string` and `cons: string` to the `Partial<{...}>` inline type in `updateCandidate`.
|
||||
**Warning signs:** TypeScript error when trying to set `pros`/`cons` in the `.set({...data})` call.
|
||||
|
||||
### Pitfall 4: CandidateCard Prop Not Threaded Through Call Sites
|
||||
|
||||
**What goes wrong:** `CandidateCard` receives new `pros`/`cons` props, but the parent component (the thread detail page / candidate list) doesn't pass them.
|
||||
**Why it happens:** Adding props to a component doesn't update callers.
|
||||
**How to avoid:** Search for all `<CandidateCard` usages and add the new prop.
|
||||
**Warning signs:** Indicator never shows even when pros/cons data is present.
|
||||
|
||||
---
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Migration SQL (Generated Output Shape)
|
||||
|
||||
```sql
|
||||
-- Expected output of bun run db:generate
|
||||
-- drizzle/000X_<tag>.sql
|
||||
ALTER TABLE `thread_candidates` ADD `pros` text;
|
||||
ALTER TABLE `thread_candidates` ADD `cons` text;
|
||||
```
|
||||
|
||||
SQLite supports `ADD COLUMN` for nullable columns without a default. Confirmed by existing migration pattern (`0003_misty_mongu.sql` uses `ALTER TABLE setup_items ADD classification text DEFAULT 'base' NOT NULL`).
|
||||
|
||||
### Service: createCandidate Values
|
||||
|
||||
```typescript
|
||||
// Source: src/server/services/thread.service.ts
|
||||
return db
|
||||
.insert(threadCandidates)
|
||||
.values({
|
||||
threadId,
|
||||
name: data.name,
|
||||
// ... existing fields ...
|
||||
pros: data.pros ?? null, // ADD
|
||||
cons: data.cons ?? null, // ADD
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
```
|
||||
|
||||
### Service: updateCandidate Inline Type
|
||||
|
||||
```typescript
|
||||
// Source: src/server/services/thread.service.ts
|
||||
export function updateCandidate(
|
||||
db: Db = prodDb,
|
||||
candidateId: number,
|
||||
data: Partial<{
|
||||
name: string;
|
||||
weightGrams: number;
|
||||
priceCents: number;
|
||||
categoryId: number;
|
||||
notes: string;
|
||||
productUrl: string;
|
||||
imageFilename: string;
|
||||
status: "researching" | "ordered" | "arrived";
|
||||
pros: string; // ADD
|
||||
cons: string; // ADD
|
||||
}>,
|
||||
) { ... }
|
||||
```
|
||||
|
||||
### Hook: CandidateResponse Interface
|
||||
|
||||
```typescript
|
||||
// Source: src/client/hooks/useCandidates.ts
|
||||
interface CandidateResponse {
|
||||
id: number;
|
||||
// ... existing ...
|
||||
pros: string | null; // ADD
|
||||
cons: string | null; // ADD
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | Notes |
|
||||
|--------------|------------------|-------|
|
||||
| Manual SQL migrations | Drizzle-kit generate + push | Already established — 4 migrations in project |
|
||||
| `notes` as freeform text | `pros`/`cons` as separate nullable TEXT columns | Matches how existing `notes` field works; no special type |
|
||||
|
||||
**Not applicable in this phase:**
|
||||
- No new libraries
|
||||
- No breaking API changes (all new fields are optional)
|
||||
- Existing candidates will have `pros = null` and `cons = null` after migration — no backfill needed
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Bullet list rendering in CandidateCard**
|
||||
- What we know: RANK-03 says "displayed as bullet lists"
|
||||
- What's unclear: The card currently shows the pros/cons indicator; does the card need to render the actual bullets, or does that happen elsewhere (e.g., a tooltip, expanded state, or comparison view in Phase 12)?
|
||||
- Recommendation: Phase 10 success criteria only requires "visual indicator when a candidate has pros or cons entered." Full bullet rendering can be deferred to Phase 12 (Comparison View) or Phase 11. The form's edit view can display raw textarea text.
|
||||
|
||||
2. **Maximum text length**
|
||||
- What we know: SQLite TEXT has no practical length limit; the existing `notes` field has no validation constraint
|
||||
- What's unclear: Should pros/cons have a max length?
|
||||
- Recommendation: Omit length constraint to stay consistent with the `notes` field. Add if user feedback indicates issues.
|
||||
|
||||
---
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
`workflow.nyquist_validation` is `true` in `.planning/config.json`.
|
||||
|
||||
### Test Framework
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Bun test runner (built-in) |
|
||||
| Config file | None — `bun test` discovers `tests/**/*.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-03 | `createCandidate` stores pros/cons and returns them | unit | `bun test tests/services/thread.service.test.ts` | Extend existing |
|
||||
| RANK-03 | `updateCandidate` can set/clear pros and cons | unit | `bun test tests/services/thread.service.test.ts` | Extend existing |
|
||||
| RANK-03 | `getThreadWithCandidates` returns pros/cons on each candidate | unit | `bun test tests/services/thread.service.test.ts` | Extend existing |
|
||||
| RANK-03 | `PUT /api/threads/:id/candidates/:id` accepts pros/cons in body | route | `bun test tests/routes/threads.test.ts` | Extend existing |
|
||||
| RANK-03 | All existing tests pass (no column drift) | regression | `bun test` | Existing ✅ |
|
||||
|
||||
### 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
|
||||
|
||||
No new test files need to be created. All tests are extensions of existing files:
|
||||
- `tests/services/thread.service.test.ts` — add `pros`/`cons` test cases to existing `describe("createCandidate")` and `describe("updateCandidate")` blocks
|
||||
- `tests/routes/threads.test.ts` — add a test case to existing `PUT` candidate describe block
|
||||
|
||||
None — existing test infrastructure covers all phase requirements (as extensions).
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
|
||||
- Direct code inspection: `src/db/schema.ts` — current `threadCandidates` column layout
|
||||
- Direct code inspection: `tests/helpers/db.ts` — `CREATE TABLE thread_candidates` raw SQL
|
||||
- Direct code inspection: `src/server/services/thread.service.ts` — `createCandidate`, `updateCandidate`, `getThreadWithCandidates`
|
||||
- Direct code inspection: `src/shared/schemas.ts` — `createCandidateSchema`, `updateCandidateSchema`
|
||||
- Direct code inspection: `src/client/components/CandidateForm.tsx` — form structure and payload
|
||||
- Direct code inspection: `src/client/components/CandidateCard.tsx` — props interface and badge rendering
|
||||
- Direct code inspection: `src/client/hooks/useCandidates.ts` — `CandidateResponse` interface
|
||||
- Direct code inspection: `drizzle/0003_misty_mongu.sql` — ALTER TABLE migration pattern
|
||||
- Direct code inspection: `CLAUDE.md` — explicit test-helper sync requirement
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
|
||||
- SQLite docs: `ALTER TABLE … ADD COLUMN` supports nullable columns without default — verified by existing migration pattern in project
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
|
||||
- None
|
||||
|
||||
---
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH — no new libraries; all tooling already in use
|
||||
- Architecture: HIGH — full codebase read confirms exact ladder; no ambiguity
|
||||
- Pitfalls: HIGH — CLAUDE.md explicitly calls out test helper drift; column projection issue confirmed by reading service code
|
||||
|
||||
**Research date:** 2026-03-16
|
||||
**Valid until:** 2026-06-16 (stable stack — 90 days)
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
phase: 10
|
||||
slug: schema-foundation-pros-cons-fields
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: 2026-03-16
|
||||
---
|
||||
|
||||
# Phase 10 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | Bun test runner (built-in) |
|
||||
| **Config file** | none — `bun test` discovers `tests/**/*.test.ts` |
|
||||
| **Quick run command** | `bun test tests/services/thread.service.test.ts` |
|
||||
| **Full suite command** | `bun test` |
|
||||
| **Estimated runtime** | ~5 seconds |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run `bun test tests/services/thread.service.test.ts`
|
||||
- **After every plan wave:** Run `bun test`
|
||||
- **Before `/gsd:verify-work`:** Full suite must be green
|
||||
- **Max feedback latency:** 5 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
|
||||
| 10-01-01 | 01 | 1 | RANK-03 | unit | `bun test tests/services/thread.service.test.ts` | Extend existing | ⬜ pending |
|
||||
| 10-01-02 | 01 | 1 | RANK-03 | unit | `bun test tests/services/thread.service.test.ts` | Extend existing | ⬜ pending |
|
||||
| 10-01-03 | 01 | 1 | RANK-03 | unit | `bun test tests/services/thread.service.test.ts` | Extend existing | ⬜ pending |
|
||||
| 10-01-04 | 01 | 1 | RANK-03 | route | `bun test tests/routes/threads.test.ts` | Extend existing | ⬜ pending |
|
||||
| 10-01-05 | 01 | 1 | RANK-03 | regression | `bun test` | Existing ✅ | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
Existing infrastructure covers all phase requirements. All tests are extensions of existing files:
|
||||
- `tests/services/thread.service.test.ts` — add `pros`/`cons` test cases to existing describe blocks
|
||||
- `tests/routes/threads.test.ts` — add test case to existing PUT candidate describe block
|
||||
|
||||
*No new test files or framework installs required.*
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| CandidateCard shows visual indicator when pros/cons present | RANK-03 | UI rendering verification | 1. Create thread with candidate 2. Add pros text 3. Verify indicator badge appears on card |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 5s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
phase: 10-schema-foundation-pros-cons-fields
|
||||
verified: 2026-03-16T21:00:00Z
|
||||
status: passed
|
||||
score: 4/4 must-haves verified
|
||||
re_verification: false
|
||||
---
|
||||
|
||||
# Phase 10: Schema Foundation Pros/Cons Fields Verification Report
|
||||
|
||||
**Phase Goal:** Candidates can be annotated with pros and cons, and the database is ready for ranking
|
||||
**Verified:** 2026-03-16T21:00:00Z
|
||||
**Status:** passed
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
| --- | ----------------------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | User can open a candidate edit form and see pros and cons text fields | VERIFIED | `CandidateForm.tsx` lines 250-284: two textarea elements with `id="candidate-pros"` and `id="candidate-cons"`, pre-filled via `candidate.pros ?? ""` in edit useEffect |
|
||||
| 2 | User can save pros and cons text; the text persists across page refreshes | VERIFIED | Form payload sends `pros: form.pros.trim() || undefined` to API; service stores `data.pros ?? null` in SQLite; migration `0004_soft_synch.sql` adds columns to live DB |
|
||||
| 3 | CandidateCard shows a visual indicator when a candidate has pros or cons entered | VERIFIED | `CandidateCard.tsx` line 181-185: `{(pros || cons) && <span ...bg-purple-50 text-purple-700...>+/- Notes</span>}` renders conditionally |
|
||||
| 4 | All existing tests pass after the schema migration (no column drift in test helper) | VERIFIED | `bun test tests/services/thread.service.test.ts` — 28 pass, 0 fail; test helper mirrors `pros TEXT, cons TEXT` columns at lines 58-59 |
|
||||
|
||||
**Score:** 4/4 truths verified
|
||||
|
||||
---
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
| ---------------------------------------------------- | ------------------------------------------------------------------ | -------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `src/db/schema.ts` | pros and cons nullable TEXT columns on threadCandidates | VERIFIED | Lines 62-63: `pros: text("pros"),` and `cons: text("cons"),` present after `status` column |
|
||||
| `tests/helpers/db.ts` | Mirrored pros/cons columns in test DB CREATE TABLE | VERIFIED | Lines 58-59: `pros TEXT,` and `cons TEXT,` present in `CREATE TABLE thread_candidates` |
|
||||
| `src/server/services/thread.service.ts` | pros/cons in createCandidate, updateCandidate, getThreadWithCandidates | VERIFIED | createCandidate lines 156-157; updateCandidate Partial type lines 175-176; getThreadWithCandidates select lines 76-77 |
|
||||
| `src/shared/schemas.ts` | pros and cons optional string fields in createCandidateSchema | VERIFIED | Lines 56-57: `pros: z.string().optional(),` and `cons: z.string().optional(),`; updateCandidateSchema inherits via `.partial()` |
|
||||
| `src/client/components/CandidateForm.tsx` | Pros and Cons textarea inputs in candidate form | VERIFIED | Lines 250-284: two labeled textareas with ids `candidate-pros` and `candidate-cons`; FormData interface lines 22-23; INITIAL_FORM lines 34-35; pre-fill lines 68-69; payload lines 119-120 |
|
||||
| `src/client/components/CandidateCard.tsx` | Visual indicator badge when pros or cons are present | VERIFIED | Props interface lines 21-22: `pros?: string | null; cons?: string | null;`; destructured at line 38-39; badge at lines 181-185 using `bg-purple-50 text-purple-700` |
|
||||
| `tests/services/thread.service.test.ts` | Tests for pros/cons in create, update, and get operations | VERIFIED | 4 new test cases: "stores and returns pros and cons" (line 152), "returns null for pros and cons when not provided" (line 165), "can set and clear pros and cons" (line 200), "includes pros and cons on each candidate" (line 113) |
|
||||
|
||||
---
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
| ----------------------------------- | -------------------------------------- | --------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| `src/db/schema.ts` | `tests/helpers/db.ts` | Manual column mirroring in CREATE TABLE | VERIFIED | `pros TEXT` and `cons TEXT` present in both locations; test helper lines 58-59 match schema lines 62-63 |
|
||||
| `src/shared/schemas.ts` | `src/server/services/thread.service.ts` | Zod-inferred CreateCandidate type used in service | VERIFIED | Service imports `CreateCandidate` from `../../shared/types.ts` (line 9); `pros` and `cons` flow through the type into `createCandidate` and `updateCandidate` |
|
||||
| `src/server/services/thread.service.ts` | `src/client/hooks/useCandidates.ts` | API JSON response includes pros/cons fields | VERIFIED | `getThreadWithCandidates` select projection explicitly includes `pros: threadCandidates.pros` and `cons: threadCandidates.cons`; `CandidateResponse` interface in hook declares `pros: string | null; cons: string | null;` |
|
||||
| `src/client/hooks/useCandidates.ts` | `src/client/components/CandidateForm.tsx` | CandidateResponse type drives form pre-fill | VERIFIED | `CandidateForm.tsx` uses `useThread` which returns candidates; pre-fill useEffect accesses `candidate.pros` and `candidate.cons` at lines 68-69 |
|
||||
| `src/client/routes/threads/$threadId.tsx` | `src/client/components/CandidateCard.tsx` | Props threaded from candidate data to card | VERIFIED | Lines 156-157 in thread route: `pros={candidate.pros}` and `cons={candidate.cons}` passed to `<CandidateCard>` |
|
||||
|
||||
---
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
| ----------- | ----------- | ----------------------------------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| RANK-03 | 10-01-PLAN | User can add pros and cons text per candidate displayed as bullet lists | SATISFIED | Pros/cons fields wired end-to-end: DB columns, migration, service, Zod schema, React form textareas, CandidateCard badge. REQUIREMENTS.md marks it `[x]` at line 21. |
|
||||
|
||||
Note: The requirement description says "displayed as bullet lists" — the form stores multi-line text and the card shows a "+/- Notes" badge indicator. The text is stored as-is (one entry per line convention per plan instructions) but is not rendered as an explicit `<ul>` bullet list. This is a visual rendering concern suitable for human verification, but the data model and edit UI fully support it.
|
||||
|
||||
**Orphaned requirements check:** REQUIREMENTS.md traceability table maps only RANK-03 to Phase 10. No additional requirements are assigned to this phase. No orphaned requirements.
|
||||
|
||||
---
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
| ---- | ---- | ------- | -------- | ------ |
|
||||
| None detected | — | — | — | — |
|
||||
|
||||
Scanned all 9 modified files for TODO/FIXME/placeholder comments, empty implementations, and console.log-only handlers. None found. The `pros: form.pros.trim() || undefined` pattern in `handleSubmit` correctly sends `undefined` (omitting the field) when empty, allowing the server to store `null` — this is intentional, not a stub.
|
||||
|
||||
---
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
#### 1. Pros/Cons Text Renders Usably in Edit Form
|
||||
|
||||
**Test:** Open a thread, click "Add Candidate", observe the form. Scroll past Notes field — two textareas labeled "Pros" and "Cons" with placeholder "One pro per line..." and "One con per line..." should appear. Enter multi-line text in each, save, re-open the candidate, and confirm text pre-fills correctly.
|
||||
**Expected:** Text persists across saves and page refreshes; form pre-fills with saved content in edit mode.
|
||||
**Why human:** Requires a running browser with API connectivity to confirm round-trip persistence.
|
||||
|
||||
#### 2. CandidateCard Badge Visibility
|
||||
|
||||
**Test:** With a candidate that has pros or cons text, view the thread candidate grid. The card should show a purple "+/- Notes" badge alongside weight/price/status badges. A candidate without pros or cons should NOT show the badge.
|
||||
**Expected:** Badge appears conditionally; absent when both fields are null/empty.
|
||||
**Why human:** Requires browser rendering to verify visual appearance and conditional display.
|
||||
|
||||
---
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No gaps found. All four observable truths are fully verified. Every artifact exists, is substantive (not a stub), and is properly wired end-to-end. The database migration (`drizzle/0004_soft_synch.sql`) is present and correct. All 28 service tests pass (24 pre-existing + 4 new). The three task commits (719f708, 7a64a18, 4f2aefe) are confirmed in the git log.
|
||||
|
||||
RANK-03 is satisfied: pros and cons fields exist in the database, flow through the service layer with full CRUD support, are accepted by Zod validation, are exposed in the API response type, are editable via textarea inputs in `CandidateForm`, pre-fill correctly in edit mode, are sent in the submit payload, and surface as a purple "+/- Notes" visual indicator on `CandidateCard` when either field has content.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-03-16T21:00:00Z_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
@@ -0,0 +1,13 @@
|
||||
# Deferred Items
|
||||
|
||||
## Pre-existing Lint Violations (Out of Scope for 10-01)
|
||||
|
||||
These Biome lint/format errors existed before phase 10-01 and are not caused by any changes in this plan. They should be addressed in a separate cleanup task.
|
||||
|
||||
- `src/client/components/WeightSummaryCard.tsx` - format violation (line length)
|
||||
- `src/client/routes/collection/index.tsx` - organizeImports, format violations
|
||||
- `src/client/routes/index.tsx` - organizeImports, format violations
|
||||
- `src/client/routes/setups/$setupId.tsx` - organizeImports violations
|
||||
- `.obsidian/workspace.json` - format violations (IDE file, should be excluded from Biome)
|
||||
|
||||
Discovered during: Task 2 lint verification
|
||||
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>
|
||||
117
.planning/phases/11-candidate-ranking/11-01-SUMMARY.md
Normal file
117
.planning/phases/11-candidate-ranking/11-01-SUMMARY.md
Normal file
@@ -0,0 +1,117 @@
|
||||
---
|
||||
phase: 11-candidate-ranking
|
||||
plan: "01"
|
||||
subsystem: database, api
|
||||
tags: [drizzle, sqlite, hono, zod, sort-order, reorder, candidates]
|
||||
|
||||
# Dependency graph
|
||||
requires: []
|
||||
provides:
|
||||
- sortOrder REAL column on threadCandidates with default 0
|
||||
- reorderCandidates service function (transaction, active-only guard)
|
||||
- PATCH /api/threads/:id/candidates/reorder endpoint with Zod validation
|
||||
- getThreadWithCandidates returns candidates ordered by sort_order ASC
|
||||
- createCandidate appends at max sort_order + 1000 (first=1000, second=2000)
|
||||
- reorderCandidatesSchema Zod validator in shared/schemas.ts
|
||||
- ReorderCandidates type in shared/types.ts
|
||||
affects: [11-02, frontend-drag-reorder, candidate-lists]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Append-at-end sort_order: query MAX(sort_order), insert at +1000 gap"
|
||||
- "Reorder transaction pattern: verify active thread, loop UPDATE sort_order = (index+1)*1000"
|
||||
- "Active-only guard in reorder: return { success: false, error } when thread status != active"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- drizzle/0005_clear_micromax.sql
|
||||
- drizzle/meta/0005_snapshot.json
|
||||
modified:
|
||||
- src/db/schema.ts
|
||||
- tests/helpers/db.ts
|
||||
- src/shared/schemas.ts
|
||||
- src/shared/types.ts
|
||||
- src/server/services/thread.service.ts
|
||||
- src/server/routes/threads.ts
|
||||
- tests/services/thread.service.test.ts
|
||||
- tests/routes/threads.test.ts
|
||||
|
||||
key-decisions:
|
||||
- "sortOrder uses REAL type (not INTEGER) to allow fractional values for future midpoint insertions without bulk rewrites"
|
||||
- "First candidate gets sort_order=1000, subsequent at +1000 gaps, giving room for future insertions"
|
||||
- "reorderCandidates uses (index+1)*1000 to space out assignments and reset gaps after each reorder"
|
||||
- "Applied migration directly via sqlite3 CLI + data backfill instead of db:push (avoided data-loss warning on existing rows)"
|
||||
|
||||
patterns-established:
|
||||
- "Reorder endpoint pattern: PATCH /:id/candidates/reorder, Zod validates orderedIds array, service returns {success, error}"
|
||||
- "Service active-only guard: check thread.status !== 'active', return {success: false, error: 'Thread not active'}"
|
||||
|
||||
requirements-completed: [RANK-01, RANK-04, RANK-05]
|
||||
|
||||
# Metrics
|
||||
duration: 4min
|
||||
completed: 2026-03-16
|
||||
---
|
||||
|
||||
# Phase 11 Plan 01: Candidate Ranking Backend Summary
|
||||
|
||||
**sortOrder REAL column, reorderCandidates transaction service, and PATCH /api/threads/:id/candidates/reorder endpoint with active-thread guard**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~4 min
|
||||
- **Started:** 2026-03-16T21:19:26Z
|
||||
- **Completed:** 2026-03-16T21:22:46Z
|
||||
- **Tasks:** 2 of 2
|
||||
- **Files modified:** 8
|
||||
|
||||
## Accomplishments
|
||||
- Added sortOrder REAL column to threadCandidates with 1000-gap append strategy
|
||||
- Implemented reorderCandidates service with transaction and active-thread guard
|
||||
- Added PATCH /api/threads/:id/candidates/reorder endpoint with Zod validation
|
||||
- getThreadWithCandidates now orders candidates by sort_order ASC
|
||||
- 10 new tests (5 service + 5 route) added; all 135 tests pass with zero regressions
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Schema, migration, service layer, and tests for sort_order + reorder** - `f01d71d` (feat)
|
||||
2. **Task 2: PATCH reorder route + route tests** - `d6acfcb` (feat)
|
||||
|
||||
_Note: TDD tasks each committed after GREEN phase._
|
||||
|
||||
## Files Created/Modified
|
||||
- `src/db/schema.ts` - Added sortOrder REAL column to threadCandidates
|
||||
- `tests/helpers/db.ts` - Added sort_order REAL NOT NULL DEFAULT 0 to CREATE TABLE
|
||||
- `src/shared/schemas.ts` - Added reorderCandidatesSchema
|
||||
- `src/shared/types.ts` - Added ReorderCandidates type, imported reorderCandidatesSchema
|
||||
- `src/server/services/thread.service.ts` - Added reorderCandidates, updated createCandidate + getThreadWithCandidates
|
||||
- `src/server/routes/threads.ts` - Added PATCH /:id/candidates/reorder route
|
||||
- `tests/services/thread.service.test.ts` - Added 5 new tests for sort_order behavior
|
||||
- `tests/routes/threads.test.ts` - Added 5 new route tests for reorder endpoint
|
||||
- `drizzle/0005_clear_micromax.sql` - Generated migration SQL for sort_order column
|
||||
- `drizzle/meta/0005_snapshot.json` - Drizzle schema snapshot
|
||||
|
||||
## Decisions Made
|
||||
- Used REAL type for sort_order (not INTEGER) to allow fractional values for future midpoint insertions
|
||||
- 1000-gap strategy: first candidate = 1000, each subsequent += 1000; reorder resets to (index+1)*1000
|
||||
- Applied migration directly via sqlite3 CLI to avoid Drizzle's data-loss warning on existing rows (db had 2 rows; column has DEFAULT 0 so no actual data loss)
|
||||
- Backfilled existing candidates with ROW_NUMBER * 1000 per thread to give proper initial ordering
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
- `bun run db:push` showed data-loss warning for adding NOT NULL column to existing rows. Applied the migration directly via sqlite3 CLI instead (`ALTER TABLE thread_candidates ADD COLUMN sort_order REAL NOT NULL DEFAULT 0`). The column has DEFAULT 0 so no actual data loss; existing rows got 0 then were backfilled to proper 1000-gap values.
|
||||
|
||||
## Next Phase Readiness
|
||||
- Backend reorder API fully operational; frontend drag-to-reorder (11-02) can now consume PATCH /api/threads/:id/candidates/reorder
|
||||
- sort_order values returned in getThreadWithCandidates response, available to frontend for drag state initialization
|
||||
|
||||
---
|
||||
*Phase: 11-candidate-ranking*
|
||||
*Completed: 2026-03-16*
|
||||
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>
|
||||
85
.planning/phases/11-candidate-ranking/11-02-SUMMARY.md
Normal file
85
.planning/phases/11-candidate-ranking/11-02-SUMMARY.md
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
phase: 11-candidate-ranking
|
||||
plan: "02"
|
||||
subsystem: client-ui
|
||||
tags: [drag-reorder, framer-motion, rank-badges, view-toggle, list-view]
|
||||
dependency_graph:
|
||||
requires: [11-01]
|
||||
provides: [drag-to-reorder-ui, rank-badges, list-grid-toggle]
|
||||
affects: [threads/$threadId.tsx, CandidateCard, CandidateListItem, uiStore, useCandidates]
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- framer-motion Reorder.Group + Reorder.Item with useDragControls for drag handle
|
||||
- tempItems pattern to prevent React Query flicker during optimistic drag
|
||||
- RankBadge exported from CandidateListItem for reuse across views
|
||||
- candidateViewMode in uiStore for list/grid toggle state
|
||||
key_files:
|
||||
created:
|
||||
- src/client/components/CandidateListItem.tsx
|
||||
modified:
|
||||
- src/client/hooks/useCandidates.ts
|
||||
- src/client/stores/uiStore.ts
|
||||
- src/client/components/CandidateCard.tsx
|
||||
- src/client/routes/threads/$threadId.tsx
|
||||
- src/client/lib/iconData.tsx
|
||||
- src/client/hooks/useThreads.ts
|
||||
decisions:
|
||||
- Resolved thread list view uses plain div (not Reorder.Group) — no drag, rank badges visible
|
||||
- handleDragEnd fires on Reorder.Group onPointerUp to debounce reorder API call
|
||||
- biome-ignore applied to useExhaustiveDependencies for thread?.candidates dep — intentional trigger
|
||||
metrics:
|
||||
duration: 4min
|
||||
completed: 2026-03-16T21:29:07Z
|
||||
tasks_completed: 3
|
||||
files_changed: 7
|
||||
---
|
||||
|
||||
# Phase 11 Plan 02: Drag-to-Reorder UI Summary
|
||||
|
||||
Drag-to-reorder candidate ranking with list/grid view toggle, gold/silver/bronze rank badges, and framer-motion Reorder.Group with tempItems flicker prevention.
|
||||
|
||||
## What Was Built
|
||||
|
||||
### Task 1: useReorderCandidates hook + uiStore view mode + CandidateListItem component
|
||||
- Added `useReorderCandidates` mutation hook to `useCandidates.ts` using `apiPatch` to hit `PATCH /api/threads/:id/candidates/reorder`
|
||||
- Added `candidateViewMode: "list" | "grid"` and `setCandidateViewMode` to `uiStore.ts`
|
||||
- Created `CandidateListItem.tsx` — horizontal card for list view with drag handle (GripVertical), RankBadge (medal icon), 48x48 image thumbnail, badge row (weight/price/category/status/pros-cons), and hover-reveal action buttons
|
||||
- Exported `RankBadge` component (gold `#D4AF37`, silver `#C0C0C0`, bronze `#CD7F32`) for reuse
|
||||
- Added `style` prop support to `LucideIcon` for colored medal icons
|
||||
- Added `pros` and `cons` fields to `CandidateWithCategory` in `useThreads.ts` (Rule 2 auto-fix — missing after Phase 10)
|
||||
|
||||
### Task 2: Thread detail page with view toggle, Reorder.Group, rank badges in grid view
|
||||
- Updated `CandidateCard.tsx`: added `rank?: number` prop and renders `<RankBadge rank={rank} />` in badge row
|
||||
- Updated `threads/$threadId.tsx`:
|
||||
- List/grid view toggle (LayoutList / LayoutGrid icons) using `candidateViewMode` from uiStore
|
||||
- Active list view: `<Reorder.Group>` wrapping `<CandidateListItem>` instances for drag-to-reorder
|
||||
- Resolved list view: plain `<div>` with `<CandidateListItem isActive={false}>` — rank badges visible, drag handles hidden
|
||||
- Grid view: existing `<CandidateCard>` grid with `rank={index + 1}` passed to each card
|
||||
- `tempItems` state: holds in-progress drag order, falls back to `thread.candidates` when null
|
||||
- `handleDragEnd`: fires `reorderMutation.mutate({ orderedIds })` and clears tempItems on settled
|
||||
- `useEffect` clears tempItems when `thread?.candidates` reference changes (fresh server data)
|
||||
|
||||
### Task 3: Human verification (auto-approved — auto_chain_active)
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 2 - Missing] Added pros/cons fields to CandidateWithCategory in useThreads.ts**
|
||||
- **Found during:** Task 1 (needed by CandidateListItem)
|
||||
- **Issue:** `CandidateWithCategory` interface in `useThreads.ts` was missing `pros` and `cons` fields added in Phase 10. CandidateListItem needed these to render the "+/- Notes" badge.
|
||||
- **Fix:** Added `pros: string | null` and `cons: string | null` to the interface
|
||||
- **Files modified:** `src/client/hooks/useThreads.ts`
|
||||
- **Commit:** acfa995
|
||||
|
||||
**2. [Rule 2 - Missing] Added style prop to LucideIcon component**
|
||||
- **Found during:** Task 1 (needed by RankBadge for medal colors)
|
||||
- **Issue:** LucideIcon only accepted `className` for styling; RankBadge needed inline `style={{ color }}` for gold/silver/bronze hex colors not achievable via Tailwind
|
||||
- **Fix:** Added optional `style?: React.CSSProperties` prop to LucideIcon and passed through to icon component
|
||||
- **Files modified:** `src/client/lib/iconData.tsx`
|
||||
- **Commit:** acfa995
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All created/modified files exist. Both task commits (acfa995, 94c07e7) confirmed in git log.
|
||||
107
.planning/phases/11-candidate-ranking/11-CONTEXT.md
Normal file
107
.planning/phases/11-candidate-ranking/11-CONTEXT.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Phase 11: Candidate Ranking - Context
|
||||
|
||||
**Gathered:** 2026-03-16
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Users can drag candidates into a priority order within a thread. The rank persists across sessions and is visually communicated with medal badges on the top 3. Drag handles and reordering are disabled on resolved threads. Comparison view, impact preview, and pros/cons editing are separate phases.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation 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' or 'trophy') 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) — **overrides roadmap success criteria #4 which said "rank badges absent on resolved thread"**; 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` — ensures immediate correct ordering
|
||||
- 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)
|
||||
|
||||
</decisions>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `CandidateCard` (`src/client/components/CandidateCard.tsx`): Current card component — needs horizontal variant for list view, or a new `CandidateListItem` component
|
||||
- `StatusBadge` (`src/client/components/StatusBadge.tsx`): Click-to-cycle pattern reusable in list view
|
||||
- `useCandidates.ts` hooks: `useCreateCandidate`, `useUpdateCandidate`, `useDeleteCandidate` — need new `useReorderCandidates` mutation
|
||||
- `useThread` hook: Returns thread with `candidates[]` array — already has all data needed, just needs sort_order ordering
|
||||
- `formatWeight`/`formatPrice` formatters: Reuse in list view card badges
|
||||
- `useWeightUnit`/`useCurrency` hooks: Already used by CandidateCard
|
||||
- `LucideIcon` helper: For GripVertical drag handle and medal rank badges
|
||||
- `uiStore` (Zustand): Add `candidateViewMode: 'list' | 'grid'` for view toggle persistence
|
||||
|
||||
### Established Patterns
|
||||
- framer-motion@12.37.0 already installed — `Reorder.Group`/`Reorder.Item` for drag ordering
|
||||
- React Query for server data, Zustand for UI-only state
|
||||
- Pill badges: blue for weight, green for price, gray for category, purple for pros/cons
|
||||
- Services accept db as first param (DI pattern for testability)
|
||||
- API validation via `@hono/zod-validator` with Zod schemas
|
||||
- Hover-reveal action buttons on CandidateCard (Winner, Delete, External link)
|
||||
|
||||
### Integration Points
|
||||
- `src/db/schema.ts`: Add `sortOrder: real("sort_order").notNull().default(0)` to `threadCandidates`
|
||||
- `src/server/services/thread.service.ts`: New `reorderCandidates()` function (transactional); update `getCandidates` to ORDER BY sort_order
|
||||
- `src/server/routes/threads.ts`: New `PATCH /:id/candidates/reorder` endpoint; reject if thread is resolved
|
||||
- `src/shared/schemas.ts`: New `reorderCandidatesSchema` (z.object with orderedIds array)
|
||||
- `src/client/routes/threads/$threadId.tsx`: Wrap candidates in `Reorder.Group`, add view toggle, use tempItems pattern
|
||||
- `src/client/hooks/useCandidates.ts`: New `useReorderCandidates` mutation hook
|
||||
- `tests/helpers/db.ts`: Update CREATE TABLE for thread_candidates to include sort_order column
|
||||
- Drizzle migration: `sort_order REAL NOT NULL DEFAULT 0` + data migration to space existing rows
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- List view cards should feel like Trello or Linear cards — horizontal layout, grip handle on the left, compact but informative
|
||||
- Drag feedback should use standard framer-motion spring animations (elevated + gap), not custom physics
|
||||
- Medal badges should use actual metallic-feeling colors (gold #D4AF37, silver #C0C0C0, bronze #CD7F32), not generic highlight colors
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
None — discussion stayed within phase scope
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 11-candidate-ranking*
|
||||
*Context gathered: 2026-03-16*
|
||||
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)
|
||||
79
.planning/phases/11-candidate-ranking/11-VALIDATION.md
Normal file
79
.planning/phases/11-candidate-ranking/11-VALIDATION.md
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
phase: 11
|
||||
slug: candidate-ranking
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: 2026-03-16
|
||||
---
|
||||
|
||||
# Phase 11 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| 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` |
|
||||
| **Estimated runtime** | ~5 seconds |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run `bun test tests/services/thread.service.test.ts`
|
||||
- **After every plan wave:** Run `bun test`
|
||||
- **Before `/gsd:verify-work`:** Full suite must be green
|
||||
- **Max feedback latency:** 5 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
|
||||
| TBD | 01 | 1 | RANK-01 | unit | `bun test tests/services/thread.service.test.ts` | ❌ W0 | ⬜ pending |
|
||||
| TBD | 01 | 1 | RANK-01 | integration | `bun test tests/routes/threads.test.ts` | ❌ W0 | ⬜ pending |
|
||||
| TBD | 01 | 1 | RANK-04 | unit | `bun test tests/services/thread.service.test.ts` | ❌ W0 | ⬜ pending |
|
||||
| TBD | 01 | 1 | RANK-05 | unit | `bun test tests/services/thread.service.test.ts` | ❌ W0 | ⬜ pending |
|
||||
| TBD | 01 | 1 | RANK-05 | integration | `bun test tests/routes/threads.test.ts` | ❌ W0 | ⬜ pending |
|
||||
| TBD | 02 | 1 | RANK-02 | manual | Visual inspection | N/A | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] 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`
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Rank badge rendering (gold/silver/bronze medal icons on top 3) | RANK-02 | No component test infrastructure; visual verification required | Open thread with 3+ candidates, verify top 3 show medal icons with correct colors |
|
||||
| Drag handle visibility and interaction | RANK-01 | Visual/interaction verification | Open active thread in list view, verify grip icons visible, drag to reorder |
|
||||
| Drag handle absent on resolved thread | RANK-05 | Visual verification | Open resolved thread, verify no grip icons, rank badges still visible (static) |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 5s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
175
.planning/phases/11-candidate-ranking/11-VERIFICATION.md
Normal file
175
.planning/phases/11-candidate-ranking/11-VERIFICATION.md
Normal file
@@ -0,0 +1,175 @@
|
||||
---
|
||||
phase: 11-candidate-ranking
|
||||
verified: 2026-03-16T23:30:00Z
|
||||
status: human_needed
|
||||
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_
|
||||
321
.planning/phases/12-comparison-view/12-01-PLAN.md
Normal file
321
.planning/phases/12-comparison-view/12-01-PLAN.md
Normal file
@@ -0,0 +1,321 @@
|
||||
---
|
||||
phase: 12-comparison-view
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- src/client/components/ComparisonTable.tsx
|
||||
- src/client/stores/uiStore.ts
|
||||
- src/client/routes/threads/$threadId.tsx
|
||||
autonomous: true
|
||||
requirements: [COMP-01, COMP-02, COMP-03, COMP-04]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "User can toggle to a Compare view when a thread has 2+ candidates"
|
||||
- "Comparison table shows all candidates side-by-side with weight, price, images, notes, links, status, pros, and cons"
|
||||
- "The lightest candidate weight cell has a blue highlight; the cheapest candidate price cell has a green highlight"
|
||||
- "Non-best cells show a gray +delta string; best cells show no delta"
|
||||
- "The table scrolls horizontally on narrow viewports while the attribute label column stays fixed on the left"
|
||||
- "Missing weight or price data displays a dash, never a misleading zero"
|
||||
- "A resolved thread shows the comparison read-only with the winner column visually marked (amber tint + trophy)"
|
||||
artifacts:
|
||||
- path: "src/client/components/ComparisonTable.tsx"
|
||||
provides: "Tabular side-by-side comparison component"
|
||||
min_lines: 120
|
||||
- path: "src/client/stores/uiStore.ts"
|
||||
provides: "Extended candidateViewMode union type including 'compare'"
|
||||
contains: "compare"
|
||||
- path: "src/client/routes/threads/$threadId.tsx"
|
||||
provides: "Compare toggle button and ComparisonTable rendering branch"
|
||||
contains: "ComparisonTable"
|
||||
key_links:
|
||||
- from: "src/client/routes/threads/$threadId.tsx"
|
||||
to: "src/client/components/ComparisonTable.tsx"
|
||||
via: "import and conditional render when candidateViewMode === 'compare'"
|
||||
pattern: "candidateViewMode.*compare"
|
||||
- from: "src/client/components/ComparisonTable.tsx"
|
||||
to: "src/client/lib/formatters.ts"
|
||||
via: "formatWeight and formatPrice for cell values and delta strings"
|
||||
pattern: "formatWeight|formatPrice"
|
||||
- from: "src/client/components/ComparisonTable.tsx"
|
||||
to: "src/client/components/CandidateListItem.tsx"
|
||||
via: "RankBadge import for rank row"
|
||||
pattern: "RankBadge"
|
||||
- from: "src/client/routes/threads/$threadId.tsx"
|
||||
to: "src/client/stores/uiStore.ts"
|
||||
via: "candidateViewMode state read and setCandidateViewMode action"
|
||||
pattern: "candidateViewMode"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the side-by-side candidate comparison table for research threads. Users toggle into compare mode from the existing view-mode bar and see all candidates as columns in a horizontally-scrollable table with sticky attribute labels, weight/price delta highlighting, and resolved-thread winner marking.
|
||||
|
||||
Purpose: Enables users to directly compare candidates on weight, price, status, notes, pros, and cons without switching between cards -- the key decision-support view for the Research & Decision Tools milestone.
|
||||
|
||||
Output: One new component (`ComparisonTable.tsx`), two modified files (`uiStore.ts`, `$threadId.tsx`).
|
||||
</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/12-comparison-view/12-CONTEXT.md
|
||||
@.planning/phases/12-comparison-view/12-RESEARCH.md
|
||||
|
||||
@src/client/stores/uiStore.ts
|
||||
@src/client/routes/threads/$threadId.tsx
|
||||
@src/client/hooks/useThreads.ts
|
||||
@src/client/lib/formatters.ts
|
||||
@src/client/components/CandidateListItem.tsx
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
||||
|
||||
From src/client/hooks/useThreads.ts:
|
||||
```typescript
|
||||
interface CandidateWithCategory {
|
||||
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;
|
||||
categoryName: string;
|
||||
categoryIcon: string;
|
||||
}
|
||||
|
||||
interface ThreadWithCandidates {
|
||||
id: number;
|
||||
name: string;
|
||||
status: "active" | "resolved";
|
||||
resolvedCandidateId: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
candidates: CandidateWithCategory[];
|
||||
}
|
||||
```
|
||||
|
||||
From src/client/lib/formatters.ts:
|
||||
```typescript
|
||||
export type WeightUnit = "g" | "oz" | "lb" | "kg";
|
||||
export type Currency = "USD" | "EUR" | "GBP" | "JPY" | "CAD" | "AUD";
|
||||
export function formatWeight(grams: number | null | undefined, unit?: WeightUnit): string; // returns "--" for null
|
||||
export function formatPrice(cents: number | null | undefined, currency?: Currency): string; // returns "--" for null
|
||||
```
|
||||
|
||||
From src/client/components/CandidateListItem.tsx:
|
||||
```typescript
|
||||
export function RankBadge({ rank }: { rank: number }): JSX.Element | null;
|
||||
// Returns null for rank > 3, renders gold/silver/bronze medal icon for 1/2/3
|
||||
```
|
||||
|
||||
From src/client/stores/uiStore.ts (lines 52-54, current state):
|
||||
```typescript
|
||||
// Current type (will be extended):
|
||||
candidateViewMode: "list" | "grid";
|
||||
setCandidateViewMode: (mode: "list" | "grid") => void;
|
||||
```
|
||||
|
||||
From src/client/lib/iconData.tsx:
|
||||
```typescript
|
||||
export function LucideIcon({ name, size, className, style }: LucideIconProps): JSX.Element;
|
||||
// Renders any Lucide icon by kebab-case name string
|
||||
```
|
||||
|
||||
From src/client/hooks/useWeightUnit.ts:
|
||||
```typescript
|
||||
export function useWeightUnit(): WeightUnit; // reads from settings API
|
||||
```
|
||||
|
||||
From src/client/hooks/useCurrency.ts:
|
||||
```typescript
|
||||
export function useCurrency(): Currency; // reads from settings API
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Build ComparisonTable component</name>
|
||||
<files>src/client/components/ComparisonTable.tsx</files>
|
||||
<action>
|
||||
Create `src/client/components/ComparisonTable.tsx` — a self-contained comparison table component.
|
||||
|
||||
**Props interface:**
|
||||
```typescript
|
||||
interface ComparisonTableProps {
|
||||
candidates: CandidateWithCategory[];
|
||||
resolvedCandidateId: number | null;
|
||||
}
|
||||
```
|
||||
Import `CandidateWithCategory` type inline (duplicate the interface locally or import from useThreads — match project convention for component-local interfaces as seen in CandidateListItem.tsx which declares its own `CandidateWithCategory`).
|
||||
|
||||
**Delta computation (useMemo):**
|
||||
- Weight deltas: Filter candidates with non-null `weightGrams`. Find the minimum. For each candidate, compute `delta = weightGrams - min`. If `delta === 0` (this IS the best), store `null` as delta string. Otherwise store `+${formatWeight(delta, unit)}`. Track `bestWeightId`. If all candidates have null weight, `bestWeightId = null`.
|
||||
- Price deltas: Same logic for `priceCents` with `formatPrice(delta, currency)`. Track `bestPriceId`.
|
||||
- Use `useWeightUnit()` and `useCurrency()` hooks for unit/currency-aware formatting.
|
||||
|
||||
**Table structure:**
|
||||
- Outer: `<div className="overflow-x-auto rounded-xl border border-gray-100">` (scroll wrapper)
|
||||
- Inner: `<table>` with `style={{ minWidth: Math.max(400, candidates.length * 180) + 'px' }}` and `className="border-collapse text-sm w-full"`
|
||||
- `<thead>`: One `<tr>` with sticky corner `<th>` (empty, for label column) + one `<th>` per candidate showing name. If `candidate.id === resolvedCandidateId`, apply `bg-amber-50 text-amber-800` and prepend a trophy icon: `<LucideIcon name="trophy" size={12} className="text-amber-600" />`.
|
||||
- `<tbody>`: Render rows using a declarative ATTRIBUTE_ROWS array (see below).
|
||||
|
||||
**Sticky left column CSS (CRITICAL):**
|
||||
Every `<td>` and `<th>` in the first (label) column MUST have: `sticky left-0 z-10 bg-white`. Without `bg-white`, scrolled content bleeds through. Use `z-10` (not higher — avoid conflicts with panels/modals).
|
||||
|
||||
**Attribute row order** (per locked decision): Image, Name, Rank, Weight (with delta), Price (with delta), Status, Product Link, Notes, Pros, Cons.
|
||||
|
||||
**Row rendering — use a declarative array pattern:**
|
||||
Define `ATTRIBUTE_ROWS` as an array of `{ key, label, render(candidate) }`. This keeps the JSX clean and makes row reordering trivial. Build this array inside the component function body (after useMemo hooks) so it can close over `weightDeltas`, `priceDeltas`, `bestWeightId`, `bestPriceId`, `unit`, `currency`.
|
||||
|
||||
**Cell renderers:**
|
||||
- **Image**: 48x48 rounded-lg container. If `imageFilename`, render `<img src="/uploads/${imageFilename}" />` with `object-cover`. Else render `<LucideIcon name={categoryIcon} size={20} className="text-gray-400" />` in a `bg-gray-50` placeholder. Use `w-12 h-12` sizing.
|
||||
- **Name**: `<span className="text-sm font-medium text-gray-900">{name}</span>`
|
||||
- **Rank**: Reuse `<RankBadge rank={index + 1} />` imported from CandidateListItem. Rank is derived from array position (candidates are already sorted by sort_order from the API).
|
||||
- **Weight**: Show `formatWeight(weightGrams, unit)` as primary value in `font-medium text-gray-900`. If this is the best (`isBest`), apply `bg-blue-50` to the `<td>`. If delta string exists (not null, not best), show delta below in `text-xs text-gray-400`. If `weightGrams` is null, show `<span className="text-gray-300">—</span>`.
|
||||
- **Price**: Same pattern as weight but with `formatPrice(priceCents, currency)` and `bg-green-50` for the best cell.
|
||||
- **Status**: Render as static text `<span className="text-xs text-gray-600">{STATUS_LABELS[status]}</span>`. Define STATUS_LABELS map: `{ researching: "Researching", ordered: "Ordered", arrived: "Arrived" }`. No click-to-cycle in compare view — comparison is for reading, not mutation.
|
||||
- **Product Link**: If `productUrl` exists, render a clickable link that calls `openExternalLink(productUrl)` from uiStore: `<button onClick={() => openExternalLink(productUrl)} className="text-xs text-blue-500 hover:underline">View</button>`. If null, render `<span className="text-gray-300">—</span>`. Links remain clickable even in resolved threads (read-only means no mutations, but navigation is fine).
|
||||
- **Notes**: If `notes` exists, render `<p className="text-xs text-gray-700 whitespace-pre-line">{notes}</p>` (whitespace-pre-line preserves newlines). If null, render em dash placeholder.
|
||||
- **Pros**: If `pros` exists, split on `"\n"`, filter empty strings, render as `<ul className="list-disc list-inside space-y-0.5">` with `<li className="text-xs text-gray-700">` items. If null, render em dash placeholder.
|
||||
- **Cons**: Same as Pros rendering.
|
||||
|
||||
**Winner column highlight (resolved threads):**
|
||||
When `resolvedCandidateId` is set, the winner's `<th>` in the header gets `bg-amber-50 text-amber-800` + trophy icon. Each body `<td>` for the winner column gets a subtle `bg-amber-50/50` tint (half-opacity amber). This must not conflict with the best-weight/best-price blue/green highlights — when both apply (winner IS also lightest), use the weight/price highlight color (it's more informative).
|
||||
|
||||
**Row styling:**
|
||||
- Each `<tr>` gets `border-b border-gray-50` for subtle row separation.
|
||||
- Label `<td>` cells: `text-xs font-medium text-gray-500 uppercase tracking-wide w-28`.
|
||||
- Data `<td>` cells: `px-4 py-3 min-w-[160px]`.
|
||||
- Header `<th>` cells: `px-4 py-3 text-left text-xs font-medium text-gray-700 min-w-[160px]`.
|
||||
|
||||
**Table border + rounding:**
|
||||
The outer wrapper has `rounded-xl border border-gray-100`. Add `overflow-hidden` to the wrapper alongside `overflow-x-auto` to clip the table's corners to the rounded border: `className="overflow-x-auto overflow-hidden rounded-xl border border-gray-100"`. Actually, use `overflow-x-auto` on an outer div, and put the border/rounding there. The table itself does not need border-radius.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun run lint 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<done>ComparisonTable.tsx exists with all 10 attribute rows, delta computation via useMemo, sticky left column with bg-white, horizontal scroll wrapper, blue/green best-cell highlights, gray delta text for non-best, amber winner column for resolved threads, em dash for missing data (never zero).</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Wire compare toggle and ComparisonTable into thread detail</name>
|
||||
<files>src/client/stores/uiStore.ts, src/client/routes/threads/$threadId.tsx</files>
|
||||
<action>
|
||||
**Step 1: Extend uiStore candidateViewMode (src/client/stores/uiStore.ts)**
|
||||
|
||||
Change the type union on lines 53-54 from:
|
||||
```typescript
|
||||
candidateViewMode: "list" | "grid";
|
||||
setCandidateViewMode: (mode: "list" | "grid") => void;
|
||||
```
|
||||
to:
|
||||
```typescript
|
||||
candidateViewMode: "list" | "grid" | "compare";
|
||||
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;
|
||||
```
|
||||
|
||||
No other changes needed in uiStore — the implementation lines 112-113 are generic and already work with the wider type.
|
||||
|
||||
**Step 2: Add compare toggle button (src/client/routes/threads/$threadId.tsx)**
|
||||
|
||||
In the toolbar toggle bar (the `<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-0.5">` block around line 146), add a third button for compare mode. The compare button should only render when `thread.candidates.length >= 2` (per locked decision).
|
||||
|
||||
Add the compare button after the grid button but inside the toggle container:
|
||||
```tsx
|
||||
{thread.candidates.length >= 2 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCandidateViewMode("compare")}
|
||||
className={`p-1.5 rounded-md transition-colors ${
|
||||
candidateViewMode === "compare"
|
||||
? "bg-gray-200 text-gray-900"
|
||||
: "text-gray-400 hover:text-gray-600"
|
||||
}`}
|
||||
title="Compare view"
|
||||
>
|
||||
<LucideIcon name="columns-3" size={16} />
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
Also: Hide the "Add Candidate" button when in compare view. Change the existing `{isActive && (` guard (around line 123) to `{isActive && candidateViewMode !== "compare" && (`. This keeps the toolbar uncluttered — users switch to list/grid to add candidates.
|
||||
|
||||
**Step 3: Add ComparisonTable rendering branch ($threadId.tsx)**
|
||||
|
||||
Import ComparisonTable at the top of the file:
|
||||
```typescript
|
||||
import { ComparisonTable } from "../../components/ComparisonTable";
|
||||
```
|
||||
|
||||
In the candidates rendering section (starting around line 192), add a compare branch BEFORE the existing list check:
|
||||
```tsx
|
||||
) : candidateViewMode === "compare" ? (
|
||||
<ComparisonTable
|
||||
candidates={displayItems}
|
||||
resolvedCandidateId={thread.resolvedCandidateId}
|
||||
/>
|
||||
) : candidateViewMode === "list" ? (
|
||||
// ... existing list rendering (unchanged)
|
||||
) : (
|
||||
// ... existing grid rendering (unchanged)
|
||||
)
|
||||
```
|
||||
|
||||
Pass `displayItems` (not `thread.candidates`) so the order reflects any pending drag reorder state, though in compare mode drag is not active — `displayItems` will equal `thread.candidates` when `tempItems` is null.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun run lint 2>&1 | head -20 && bun test 2>&1 | tail -5</automated>
|
||||
</verify>
|
||||
<done>uiStore accepts "compare" as a candidateViewMode value. Thread detail page shows a third toggle icon (columns-3) when 2+ candidates exist. Clicking it renders ComparisonTable. "Add Candidate" button is hidden in compare mode. Existing list/grid views still work unchanged. All existing tests pass.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. `bun run lint` passes with no errors
|
||||
2. `bun test` full suite passes (no backend changes, existing tests unaffected)
|
||||
3. Manual browser verification:
|
||||
- Navigate to a thread with 2+ candidates
|
||||
- Verify the compare icon (columns-3) appears in the toggle bar
|
||||
- Click compare icon -> tabular comparison renders with candidates as columns
|
||||
- Verify attribute row order: Image, Name, Rank, Weight, Price, Status, Link, Notes, Pros, Cons
|
||||
- Verify lightest weight cell has blue-50 tint, cheapest price cell has green-50 tint
|
||||
- Verify non-best cells show gray +delta text
|
||||
- Verify missing weight/price shows em dash (not zero)
|
||||
- Resize viewport narrow -> table scrolls horizontally, label column stays fixed
|
||||
- Navigate to a resolved thread -> winner column has amber tint + trophy, no mutation controls
|
||||
- Toggle back to list/grid views -> they still work correctly
|
||||
- Thread with 0 or 1 candidate -> compare icon does not appear
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- ComparisonTable.tsx renders all 10 attribute rows with correct data
|
||||
- Delta highlighting: blue-50 on lightest weight, green-50 on cheapest price, gray delta text on non-best
|
||||
- Sticky label column with solid bg-white stays visible during horizontal scroll
|
||||
- Resolved threads show winner column with amber-50 tint and trophy icon
|
||||
- Missing data renders as em dash, never as zero (COMP-04)
|
||||
- Compare toggle icon appears only when >= 2 candidates
|
||||
- All existing tests continue to pass
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/12-comparison-view/12-01-SUMMARY.md`
|
||||
</output>
|
||||
110
.planning/phases/12-comparison-view/12-01-SUMMARY.md
Normal file
110
.planning/phases/12-comparison-view/12-01-SUMMARY.md
Normal file
@@ -0,0 +1,110 @@
|
||||
---
|
||||
phase: 12-comparison-view
|
||||
plan: "01"
|
||||
subsystem: ui
|
||||
tags: [react, tailwind, comparison-table, zustand, framer-motion]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 11-candidate-ranking
|
||||
provides: RankBadge component and sort_order-based candidate ordering
|
||||
- phase: 10-schema-foundation-pros-cons-fields
|
||||
provides: pros/cons fields on CandidateWithCategory type
|
||||
provides:
|
||||
- ComparisonTable component with sticky label column and horizontal scroll
|
||||
- candidateViewMode "compare" value in uiStore
|
||||
- Compare toggle in thread detail toolbar (visible when 2+ candidates)
|
||||
- Weight/price delta highlighting with best-cell color coding
|
||||
- Resolved thread winner column marking (amber tint + trophy)
|
||||
affects: [future comparison features, thread detail enhancements]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- Declarative ATTRIBUTE_ROWS array pattern for table row rendering (key, label, render, cellClass)
|
||||
- useMemo delta computation for best-cell identification in comparison views
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- src/client/components/ComparisonTable.tsx
|
||||
modified:
|
||||
- src/client/stores/uiStore.ts
|
||||
- src/client/routes/threads/$threadId.tsx
|
||||
|
||||
key-decisions:
|
||||
- "ATTRIBUTE_ROWS declarative array pattern keeps JSX clean and row reordering trivial"
|
||||
- "cellClass function pattern in ATTRIBUTE_ROWS allows per-row cell styling without duplicating winner-check logic in every render"
|
||||
- "Compare toggle only shown when >= 2 candidates (locked decision from plan)"
|
||||
- "Add Candidate button hidden in compare view — compare is for reading, not mutation"
|
||||
- "Winner highlight priority: weight/price color wins over amber tint when both apply (more informative)"
|
||||
|
||||
patterns-established:
|
||||
- "Declarative table row config: ATTRIBUTE_ROWS array with { key, label, render, cellClass } objects"
|
||||
- "Sticky left column pattern: sticky left-0 z-10 bg-white on every label cell for scroll bleed prevention"
|
||||
|
||||
requirements-completed: [COMP-01, COMP-02, COMP-03, COMP-04]
|
||||
|
||||
# Metrics
|
||||
duration: 2min
|
||||
completed: 2026-03-17
|
||||
---
|
||||
|
||||
# Phase 12 Plan 01: Comparison View Summary
|
||||
|
||||
**Side-by-side candidate comparison table with sticky labels, weight/price delta highlighting, and resolved-thread winner marking via a new "compare" candidateViewMode**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 2 min
|
||||
- **Started:** 2026-03-17T14:28:12Z
|
||||
- **Completed:** 2026-03-17T14:30:00Z
|
||||
- **Tasks:** 2
|
||||
- **Files modified:** 3
|
||||
|
||||
## Accomplishments
|
||||
- Built ComparisonTable component with 10 attribute rows (Image, Name, Rank, Weight, Price, Status, Link, Notes, Pros, Cons) using declarative ATTRIBUTE_ROWS pattern
|
||||
- Implemented useMemo delta computation — lightest weight cell highlighted blue-50, cheapest price cell green-50, non-best cells show gray +delta string
|
||||
- Sticky left column with bg-white prevents content bleed-through on horizontal scroll
|
||||
- Amber tint + trophy icon on winner column in resolved threads; weight/price color takes priority when winner is also best
|
||||
- Extended uiStore candidateViewMode to "list" | "grid" | "compare" and wired compare toggle in thread detail toolbar
|
||||
- Compare toggle only appears when thread has 2+ candidates; Add Candidate button hidden in compare view
|
||||
- All 135 existing tests pass, no regressions
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Build ComparisonTable component** - `e442b33` (feat)
|
||||
2. **Task 2: Wire compare toggle and ComparisonTable into thread detail** - `5b4026d` (feat)
|
||||
|
||||
## Files Created/Modified
|
||||
- `src/client/components/ComparisonTable.tsx` - New comparison table component with all 10 attribute rows, delta computation, sticky labels, and winner highlighting
|
||||
- `src/client/stores/uiStore.ts` - Extended candidateViewMode union to include "compare"
|
||||
- `src/client/routes/threads/$threadId.tsx` - Added ComparisonTable import, compare toggle button (columns-3 icon), ComparisonTable rendering branch, and "Add Candidate" hidden in compare view
|
||||
|
||||
## Decisions Made
|
||||
- ATTRIBUTE_ROWS declarative array pattern keeps table JSX clean and row reordering trivial — each row is just { key, label, render, cellClass }
|
||||
- cellClass function in ATTRIBUTE_ROWS allows per-row cell styling without duplicating winner-check logic in every render function
|
||||
- Compare toggle only shown for 2+ candidates per locked plan decision
|
||||
- Add Candidate button hidden in compare view to keep toolbar uncluttered (users switch to list/grid to add)
|
||||
- When winner IS also the lightest/cheapest, weight/price color (blue/green) takes priority over amber tint — more informative
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
- Minor formatting differences caught by Biome auto-formatter (indentation depth in conditional JSX) — resolved with `biome check --write`. No logic changes.
|
||||
|
||||
## User Setup Required
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
- ComparisonTable is complete and functional; compare mode wired end-to-end in thread detail
|
||||
- No blockers — ready for any follow-on comparison view enhancements
|
||||
- All existing tests pass; no backend changes needed
|
||||
|
||||
---
|
||||
*Phase: 12-comparison-view*
|
||||
*Completed: 2026-03-17*
|
||||
541
.planning/phases/12-comparison-view/12-RESEARCH.md
Normal file
541
.planning/phases/12-comparison-view/12-RESEARCH.md
Normal file
@@ -0,0 +1,541 @@
|
||||
# Phase 12: Comparison View - Research
|
||||
|
||||
**Researched:** 2026-03-17
|
||||
**Domain:** React tabular UI, CSS sticky columns, horizontal scroll, delta computation
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 12 is a pure frontend phase. No backend changes, no schema changes, no new npm packages. All required data is already returned by `useThread(threadId)` — candidates carry `weightGrams`, `priceCents`, `status`, `productUrl`, `notes`, `pros`, `cons`, `imageFilename`, `categoryIcon`, and rank is derived from sort_order position in the array. The work is entirely in building a `ComparisonTable` component, wiring a third toggle button into the existing view-mode bar, and extending the `candidateViewMode` Zustand union type.
|
||||
|
||||
The core CSS challenge is the sticky-first-column + horizontal-scroll table pattern. Modern CSS handles this well as long as `overflow-x: auto` is placed on a wrapper `<div>`, not the `<table>` element itself, and the sticky `<td>` cells in the label column have an explicit background color (otherwise scrolling content bleeds through). Z-index layering is simple for this use case because there is only one sticky axis (the left label column); no sticky top header is needed since the table is not vertically scrollable.
|
||||
|
||||
Delta computation is straightforward arithmetic: find the minimum `weightGrams` across candidates that have a value, subtract each candidate's value from that minimum to produce a delta, and render a `+Xg` or `—` string. The "best" cell gets `bg-blue-50` for weight (matching existing blue weight pill color) or `bg-green-50` for price (matching existing green price pill color). Missing data must never display as "0" — a dash placeholder is required by COMP-04, and `formatWeight(null)` already returns `"--"`.
|
||||
|
||||
**Primary recommendation:** Build `ComparisonTable.tsx` as a self-contained component that accepts `candidates[]` and `resolvedCandidateId | null`, computes deltas internally with `useMemo`, renders a `<div className="overflow-x-auto">` wrapper around a plain `<table>`, and uses `sticky left-0 bg-white z-10` on the label `<td>` cells.
|
||||
|
||||
---
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
- Compare mode entry point: Add a third icon to the existing list/grid toggle bar, making it list | grid | compare (three-way toggle)
|
||||
- Use `candidateViewMode: 'list' | 'grid' | 'compare'` in uiStore — extends the existing Zustand state
|
||||
- Compare icon only appears when 2+ candidates exist in the thread (hidden otherwise)
|
||||
- Table orientation: Candidates as columns, attribute labels as rows (classic product-comparison pattern — Amazon/Wirecutter style)
|
||||
- Sticky left column for attribute labels; table scrolls horizontally on narrow viewports
|
||||
- Attribute row order: Image → Name → Rank → Weight (with delta) → Price (with delta) → Status → Product Link → Notes → Pros → Cons
|
||||
- Delta highlighting style: Lightest candidate's weight cell gets a subtle colored background tint (e.g., bg-green-50); cheapest similarly
|
||||
- Non-best cells show delta text in neutral gray — no colored badges for deltas, only the "best" cell gets color
|
||||
|
||||
### Claude's Discretion
|
||||
- "Add Candidate" button visibility when in compare view
|
||||
- Image thumbnail sizing in comparison cells (square crop vs wider aspect)
|
||||
- Multi-line text rendering strategy (clamped with expand vs full text)
|
||||
- Missing data indicator style (dash with label, empty cell, etc.)
|
||||
- Delta format: absolute value + delta underneath, or delta only for non-best cells
|
||||
- Winner column marking approach (column tint, trophy icon, or both)
|
||||
- Resolved thread interactivity (links clickable vs all read-only)
|
||||
- Resolution banner behavior in compare view
|
||||
- View mode persistence (already in Zustand — whether compare resets on navigation or persists)
|
||||
- Compare toggle icon choice (e.g., Lucide `columns-3`, `table-2`, or similar)
|
||||
- Table cell padding, border styling, and overall table chrome
|
||||
- Column minimum/maximum widths
|
||||
- Keyboard accessibility for horizontal scrolling
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
None — discussion stayed within phase scope
|
||||
</user_constraints>
|
||||
|
||||
---
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|-----------------|
|
||||
| COMP-01 | User can view candidates side-by-side in a tabular comparison layout (weight, price, images, notes, links, status) | ComparisonTable component; all fields available from useThread hook; no backend changes needed |
|
||||
| COMP-02 | User can see relative deltas highlighting the lightest and cheapest candidate with +/- differences | Delta computation via array reduce; best-cell highlight via bg-blue-50 (weight) / bg-green-50 (price); gray delta text for non-best |
|
||||
| COMP-03 | Comparison table scrolls horizontally with a sticky label column on narrow viewports | overflow-x-auto wrapper div + sticky left-0 bg-white z-10 on label td cells |
|
||||
| COMP-04 | Comparison view displays read-only summary for resolved threads | resolvedCandidateId from useThread; disable mutation actions; winner column visual tint; resolved check pattern established in Phase 11 |
|
||||
</phase_requirements>
|
||||
|
||||
---
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core (all already installed — no new packages needed)
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| React 19 | ^19.2.4 | Component rendering | Project stack |
|
||||
| Tailwind CSS | v4 | Utility styling | Project stack |
|
||||
| Zustand | ^5.0.11 | candidateViewMode state | Already used for list/grid toggle |
|
||||
| lucide-react | ^0.577.0 | Toggle icon (`columns-3` confirmed present) | All icons use LucideIcon helper |
|
||||
| framer-motion | ^12.37.0 | Optional AnimatePresence for view transition | Already installed |
|
||||
|
||||
### Supporting Utilities (already in project)
|
||||
| Utility | Location | Purpose |
|
||||
|---------|----------|---------|
|
||||
| `formatWeight(grams, unit)` | `src/client/lib/formatters.ts` | Weight cell values and delta strings; returns `"--"` for null |
|
||||
| `formatPrice(cents, currency)` | `src/client/lib/formatters.ts` | Price cell values and delta strings; returns `"--"` for null |
|
||||
| `useWeightUnit()` | `src/client/hooks/useWeightUnit.ts` | Current unit setting |
|
||||
| `useCurrency()` | `src/client/hooks/useCurrency.ts` | Current currency setting |
|
||||
| `useThread(threadId)` | `src/client/hooks/useThreads.ts` | All candidate data |
|
||||
| `RankBadge` | `src/client/components/CandidateListItem.tsx` | Rank medal icons (exported) |
|
||||
| `LucideIcon` | `src/client/lib/iconData.tsx` | Icon rendering with fallback |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Recommended File Structure
|
||||
```
|
||||
src/client/
|
||||
├── components/
|
||||
│ └── ComparisonTable.tsx # New: tabular comparison component
|
||||
├── stores/
|
||||
│ └── uiStore.ts # Modify: extend candidateViewMode union type
|
||||
└── routes/threads/
|
||||
└── $threadId.tsx # Modify: add compare branch + third toggle button
|
||||
```
|
||||
|
||||
### Pattern 1: Sticky Left Column with Horizontal Scroll
|
||||
|
||||
**What:** Wrap `<table>` in `<div className="overflow-x-auto">`. Apply `sticky left-0 bg-white z-10` to every `<td>` and `<th>` in the first (label) column.
|
||||
|
||||
**When to use:** Any time a table needs a frozen left column with horizontal scrolling.
|
||||
|
||||
**Critical pitfall:** The sticky `td` cells MUST have a solid background color. Without `bg-white`, scrolling content bleeds through the "sticky" cell because the cell is transparent.
|
||||
|
||||
**Example:**
|
||||
```tsx
|
||||
// Outer wrapper enables horizontal scroll
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-100">
|
||||
<table
|
||||
className="border-collapse text-sm"
|
||||
style={{ minWidth: `${Math.max(400, candidates.length * 180)}px` }}
|
||||
>
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100">
|
||||
{/* Sticky corner cell — bg-white mandatory */}
|
||||
<th className="sticky left-0 z-10 bg-white px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wide w-28" />
|
||||
{candidates.map((c) => (
|
||||
<th key={c.id} className="px-4 py-3 text-left text-xs font-medium text-gray-700 min-w-[160px]">
|
||||
{c.name}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ATTRIBUTE_ROWS.map((row) => (
|
||||
<tr key={row.key} className="border-b border-gray-50">
|
||||
{/* Sticky label cell — bg-white mandatory */}
|
||||
<td className="sticky left-0 z-10 bg-white px-4 py-3 text-xs font-medium text-gray-500">
|
||||
{row.label}
|
||||
</td>
|
||||
{candidates.map((c) => (
|
||||
<td key={c.id} className="px-4 py-3">
|
||||
{row.render(c)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Pattern 2: Delta Computation (null-safe, useMemo)
|
||||
|
||||
**What:** Derive the "best" candidate and compute deltas before rendering. Use `useMemo` keyed on `candidates` to avoid recomputing on every render.
|
||||
|
||||
**Example:**
|
||||
```tsx
|
||||
// Source: derived from project formatters.ts patterns
|
||||
const { weightDeltas, bestWeightId } = useMemo(() => {
|
||||
const withWeight = candidates.filter((c) => c.weightGrams != null);
|
||||
if (withWeight.length === 0) return { weightDeltas: new Map<number, string | null>(), bestWeightId: null };
|
||||
|
||||
const minGrams = Math.min(...withWeight.map((c) => c.weightGrams as number));
|
||||
const bestWeightId = withWeight.find((c) => c.weightGrams === minGrams)!.id;
|
||||
|
||||
const weightDeltas = new Map(
|
||||
candidates.map((c) => {
|
||||
if (c.weightGrams == null) return [c.id, null]; // null = missing data
|
||||
const delta = c.weightGrams - minGrams;
|
||||
return [c.id, delta === 0 ? null : `+${formatWeight(delta, unit)}`];
|
||||
// delta === 0 means this IS the best — no delta string needed
|
||||
})
|
||||
);
|
||||
return { weightDeltas, bestWeightId };
|
||||
}, [candidates, unit]);
|
||||
```
|
||||
|
||||
### Pattern 3: Extending Zustand Union Type
|
||||
|
||||
**What:** Widen the existing `candidateViewMode` type from `'list' | 'grid'` to `'list' | 'grid' | 'compare'`. The implementation setter line is unchanged.
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// In uiStore.ts — only two type declaration lines change (lines 53-54):
|
||||
candidateViewMode: "list" | "grid" | "compare";
|
||||
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;
|
||||
|
||||
// Implementation lines 112-113 — unchanged:
|
||||
candidateViewMode: "list",
|
||||
setCandidateViewMode: (mode) => set({ candidateViewMode: mode }),
|
||||
```
|
||||
|
||||
### Pattern 4: Three-Way Toggle Button
|
||||
|
||||
**What:** Add a third button to the existing `bg-gray-100 rounded-lg p-0.5` toggle bar in `$threadId.tsx`. Show compare button only when `thread.candidates.length >= 2`.
|
||||
|
||||
**Example:**
|
||||
```tsx
|
||||
{thread.candidates.length >= 2 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCandidateViewMode("compare")}
|
||||
className={`p-1.5 rounded-md transition-colors ${
|
||||
candidateViewMode === "compare"
|
||||
? "bg-gray-200 text-gray-900"
|
||||
: "text-gray-400 hover:text-gray-600"
|
||||
}`}
|
||||
title="Compare view"
|
||||
>
|
||||
<LucideIcon name="columns-3" size={16} />
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
**Confirmed:** `columns-3` maps to `Columns3` in lucide-react ^0.577.0 and is present in the installed package (verified via `node -e "const {icons}=require('lucide-react'); console.log('Columns3' in icons)"`). Use `LucideIcon name="columns-3"` — the LucideIcon helper handles the `toPascalCase` conversion.
|
||||
|
||||
### Pattern 5: Row Definition as Data
|
||||
|
||||
**What:** Define the attribute rows as a declarative array, not hard-coded JSX branches. Each entry has a `key`, `label`, and a `render(candidate)` function. This makes row reordering trivial and matches the locked attribute order.
|
||||
|
||||
**Example:**
|
||||
```tsx
|
||||
// Attribute row order per CONTEXT.md: Image → Name → Rank → Weight → Price → Status → Link → Notes → Pros → Cons
|
||||
const ATTRIBUTE_ROWS = [
|
||||
{ key: "image", label: "Image", render: (c: C) => <ImageCell candidate={c} /> },
|
||||
{ key: "name", label: "Name", render: (c: C) => <span className="text-sm font-medium text-gray-900">{c.name}</span> },
|
||||
{ key: "rank", label: "Rank", render: (c: C) => <RankBadge rank={rankOf(c)} /> },
|
||||
{ key: "weight", label: "Weight", render: (c: C) => <WeightCell candidate={c} delta={weightDeltas.get(c.id)} isBest={c.id === bestWeightId} unit={unit} /> },
|
||||
{ key: "price", label: "Price", render: (c: C) => <PriceCell candidate={c} delta={priceDeltas.get(c.id)} isBest={c.id === bestPriceId} currency={currency} /> },
|
||||
{ key: "status", label: "Status", render: (c: C) => <span className="text-xs text-gray-600">{STATUS_LABELS[c.status]}</span> },
|
||||
{ key: "link", label: "Link", render: (c: C) => c.productUrl ? <a href="#" onClick={() => openExternalLink(c.productUrl!)} className="text-xs text-blue-500 hover:underline">View</a> : <span className="text-gray-300">—</span> },
|
||||
{ key: "notes", label: "Notes", render: (c: C) => <TextCell text={c.notes} /> },
|
||||
{ key: "pros", label: "Pros", render: (c: C) => <BulletCell text={c.pros} /> },
|
||||
{ key: "cons", label: "Cons", render: (c: C) => <BulletCell text={c.cons} /> },
|
||||
];
|
||||
```
|
||||
|
||||
### Pattern 6: Pros/Cons Rendering (confirmed newline-separated)
|
||||
|
||||
**What:** `CandidateForm.tsx` uses a `<textarea>` with placeholder "One pro per line..." — users enter newline-separated text. The form submits `form.pros.trim() || undefined`, so empty = `undefined` → stored as `null` in DB. Non-empty content is raw text with `\n` separators.
|
||||
|
||||
**How to render in compare table:**
|
||||
```tsx
|
||||
function BulletCell({ text }: { text: string | null }) {
|
||||
if (!text) return <span className="text-gray-300">—</span>;
|
||||
const items = text.split("\n").filter(Boolean);
|
||||
if (items.length === 0) return <span className="text-gray-300">—</span>;
|
||||
return (
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
{items.map((item, i) => (
|
||||
<li key={i} className="text-xs text-gray-700">{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- **Setting `overflow-x-auto` on `<table>` directly:** Has no effect in CSS. Must be on a wrapper `<div>`.
|
||||
- **Transparent sticky cells:** Sticky `<td>` cells without `bg-white` let scrolled content bleed through visually.
|
||||
- **Computing deltas inside render:** Use `useMemo` — compute once, not per render cycle.
|
||||
- **Using `overflow: hidden` on any ancestor of the sticky column:** Breaks the sticky positioning context.
|
||||
- **Missing data shown as "0":** `formatWeight(null)` already returns `"--"`. Guard delta computation with null checks before arithmetic.
|
||||
- **Rendering pros/cons as raw string:** Split on `\n` and render as `<ul>` — the form stores `\n`-separated text.
|
||||
|
||||
---
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Weight/price formatting | Custom format functions | `formatWeight()` / `formatPrice()` in `formatters.ts` | Handles all units, currencies, null — returns `"--"` for null |
|
||||
| Rank medal icons | Custom SVG or color dots | `RankBadge` from `CandidateListItem.tsx` | Already exported, handles ranks 1-3 with correct colors |
|
||||
| Zustand state | Local useState for view mode | Existing `candidateViewMode` in `uiStore` | Persists across navigation, consistent with list/grid |
|
||||
| Icon rendering | Direct lucide component imports | `LucideIcon` helper from `iconData.tsx` | Handles fallback, consistent API across project |
|
||||
| Unit/currency awareness | Hardcode "g" or "$" | `useWeightUnit()` / `useCurrency()` | Reads from user settings |
|
||||
|
||||
**Key insight:** This phase is almost entirely composition of already-built primitives. The delta computation logic and sticky column CSS are the only genuinely new work.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Sticky Cell Background Bleed-Through
|
||||
**What goes wrong:** The label column appears sticky but scrolling content renders on top of it, making text illegible.
|
||||
**Why it happens:** `position: sticky` keeps the element in its visual position but does not create an opaque layer. Without a background color the cell is transparent.
|
||||
**How to avoid:** Add `bg-white` to every sticky `<td>` and `<th>` in the label column. If alternating row backgrounds are used, the sticky cells must also match those background colors.
|
||||
**Warning signs:** Label text becomes unreadable when scrolling horizontally.
|
||||
|
||||
### Pitfall 2: overflow-x-auto on Wrong Element
|
||||
**What goes wrong:** The table never scrolls horizontally regardless of viewport width.
|
||||
**Why it happens:** CSS `overflow` properties only apply to block/flex/grid containers. `<table>` is a table container — `overflow-x: auto` on `<table>` has no effect per CSS spec.
|
||||
**How to avoid:** Wrap `<table>` in `<div className="overflow-x-auto">`. Set `minWidth` on the `<table>` itself (not the wrapper) to force scrollability.
|
||||
**Warning signs:** Table content wraps aggressively instead of scrolling; columns collapse on narrow screens.
|
||||
|
||||
### Pitfall 3: Delta Shows for Best Candidate
|
||||
**What goes wrong:** The lightest candidate shows "+0g" instead of just the value cleanly.
|
||||
**Why it happens:** Naive `delta = candidate - min` yields 0 for the best candidate.
|
||||
**How to avoid:** When `delta === 0`, return `null` for the delta string. The best-cell highlight color already communicates "this is best." Only non-best cells show a delta string.
|
||||
**Warning signs:** Best cell shows "+0g" or "+$0.00" alongside the colored highlight.
|
||||
|
||||
### Pitfall 4: Missing Data Rendered as Zero (COMP-04 violation)
|
||||
**What goes wrong:** A candidate with `weightGrams: null` shows "0g" in the weight row, misleading the user.
|
||||
**Why it happens:** Passing `null` through subtraction arithmetic silently produces 0 in JavaScript.
|
||||
**How to avoid:** Guard before computing: `if (c.weightGrams == null) return [c.id, null]`. In the cell renderer, when value is null, render `—` (em dash).
|
||||
**Warning signs:** COMP-04 violated; user appears to see "0g" for an item with no weight entered.
|
||||
|
||||
### Pitfall 5: z-index Conflicts with Panels/Dropdowns
|
||||
**What goes wrong:** Sticky label column renders above the SlideOutPanel or modal overlays.
|
||||
**Why it happens:** Using `z-index: 50` or higher on sticky cells competes with panel z-index values.
|
||||
**How to avoid:** Use `z-index: 10` (Tailwind `z-10`) for sticky cells. They only need to be above the regular table body cells (z-index: auto). The compare view has no interactive StatusBadge dropdowns (read-only in resolved mode; in active mode the compare view is navigational, not mutation-focused).
|
||||
**Warning signs:** Sticky column clips or obscures slide-out panels.
|
||||
|
||||
### Pitfall 6: Pros/Cons Rendered as Raw String
|
||||
**What goes wrong:** A candidate's pros appear as a single run-on text block with no formatting.
|
||||
**Why it happens:** `CandidateForm` stores pros/cons as newline-separated plain text. Plain JSX `{candidate.pros}` ignores newlines in HTML.
|
||||
**How to avoid:** Split on `"\n"`, filter empty strings, render as `<ul>/<li>`. Confirmed from `CandidateForm.tsx` textarea with "One pro per line..." placeholder.
|
||||
**Warning signs:** All pro/con items concatenated without separation.
|
||||
|
||||
---
|
||||
|
||||
## Code Examples
|
||||
|
||||
Verified patterns from project source:
|
||||
|
||||
### Extending uiStore candidateViewMode
|
||||
```typescript
|
||||
// src/client/stores/uiStore.ts — lines 53-54 today read:
|
||||
// candidateViewMode: "list" | "grid";
|
||||
// setCandidateViewMode: (mode: "list" | "grid") => void;
|
||||
|
||||
// After change (only these two lines change in the interface):
|
||||
candidateViewMode: "list" | "grid" | "compare";
|
||||
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;
|
||||
|
||||
// Implementation at lines 112-113 — no change needed:
|
||||
candidateViewMode: "list",
|
||||
setCandidateViewMode: (mode) => set({ candidateViewMode: mode }),
|
||||
```
|
||||
|
||||
### threadId.tsx integration point (current line 192)
|
||||
```tsx
|
||||
// Current conditional rendering at line 192:
|
||||
// ) : candidateViewMode === "list" ? (
|
||||
// <Reorder.Group ... /> or <div ... />
|
||||
// ) : (
|
||||
// <div className="grid ..."> (grid view)
|
||||
// )
|
||||
|
||||
// After change — add compare branch before list check:
|
||||
} : candidateViewMode === "compare" ? (
|
||||
<ComparisonTable
|
||||
candidates={displayItems}
|
||||
resolvedCandidateId={thread.resolvedCandidateId}
|
||||
/>
|
||||
) : candidateViewMode === "list" ? (
|
||||
// list rendering (unchanged)
|
||||
) : (
|
||||
// grid rendering (unchanged)
|
||||
)
|
||||
```
|
||||
|
||||
### Minimum viable ComparisonTable props interface
|
||||
```typescript
|
||||
// Reuse the CandidateWithCategory type from hooks/useThreads.ts
|
||||
interface ComparisonTableProps {
|
||||
candidates: CandidateWithCategory[]; // already typed in useThreads.ts
|
||||
resolvedCandidateId: number | null; // for winner column highlight
|
||||
}
|
||||
```
|
||||
|
||||
### Full delta computation with useMemo (null-safe)
|
||||
```typescript
|
||||
const { priceDeltas, bestPriceId } = useMemo(() => {
|
||||
const withPrice = candidates.filter((c) => c.priceCents != null);
|
||||
if (withPrice.length === 0) {
|
||||
return { priceDeltas: new Map<number, string | null>(), bestPriceId: null };
|
||||
}
|
||||
const minCents = Math.min(...withPrice.map((c) => c.priceCents as number));
|
||||
const bestPriceId = withPrice.find((c) => c.priceCents === minCents)!.id;
|
||||
const priceDeltas = new Map(
|
||||
candidates.map((c) => {
|
||||
if (c.priceCents == null) return [c.id, null]; // missing data
|
||||
const delta = c.priceCents - minCents;
|
||||
return [c.id, delta === 0 ? null : `+${formatPrice(delta, currency)}`];
|
||||
})
|
||||
);
|
||||
return { priceDeltas, bestPriceId };
|
||||
}, [candidates, currency]);
|
||||
```
|
||||
|
||||
### Best-cell highlight pattern (weight example)
|
||||
```tsx
|
||||
// Weight cell — bg-blue-50 for "lightest" (matches existing blue weight pills in CandidateListItem)
|
||||
function WeightCell({ candidate, delta, isBest, unit }: WeightCellProps) {
|
||||
return (
|
||||
<td className={`px-4 py-3 text-sm ${isBest ? "bg-blue-50" : ""}`}>
|
||||
{candidate.weightGrams != null ? (
|
||||
<>
|
||||
<span className="font-medium text-gray-900">
|
||||
{formatWeight(candidate.weightGrams, unit)}
|
||||
</span>
|
||||
{delta && (
|
||||
<span className="block text-xs text-gray-400 mt-0.5">{delta}</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Note: CONTEXT.md uses "bg-green-50" as the example for lightest weight. Recommend aligning with existing project badge colors: lightest weight → `bg-blue-50` (consistent with blue weight pills), cheapest price → `bg-green-50` (consistent with green price pills). This is within Claude's discretion.
|
||||
|
||||
### Winner column pattern (resolved threads)
|
||||
```tsx
|
||||
// Column header for the winning candidate gets amber tint (matches resolution banner)
|
||||
<th
|
||||
key={c.id}
|
||||
className={`px-4 py-3 text-left text-xs font-medium min-w-[160px] ${
|
||||
c.id === resolvedCandidateId
|
||||
? "bg-amber-50 text-amber-800"
|
||||
: "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{c.id === resolvedCandidateId && (
|
||||
<LucideIcon name="trophy" size={12} className="text-amber-600" />
|
||||
)}
|
||||
{c.name}
|
||||
</div>
|
||||
</th>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | Notes |
|
||||
|--------------|------------------|-------|
|
||||
| Hand-rolled format functions | Reuse `formatWeight` / `formatPrice` with delta arithmetic | Project formatters already handle all units, currencies, and null |
|
||||
| `overflow-x: auto` on `<table>` | `overflow-x-auto` on wrapper `<div>` | CSS spec: overflow only applies to block containers |
|
||||
| JS-based sticky columns | CSS `position: sticky` with `left: 0` | 92%+ browser support, zero JS overhead |
|
||||
| Inline column rendering | Declarative row-definition array | Matches the locked attribute order, easy to maintain |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- Direct lucide icon component imports (e.g., `import { LayoutList } from "lucide-react"`): project uses `LucideIcon` helper uniformly — follow the same pattern.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
All questions resolved during research:
|
||||
|
||||
1. **Pros/cons storage format — RESOLVED**
|
||||
- `CandidateForm.tsx` uses a `<textarea>` with "One pro per line..." placeholder
|
||||
- Submit handler: `pros: form.pros.trim() || undefined` — empty string → not sent → stored as `null`
|
||||
- Non-empty content: raw multiline text stored as-is, newline-separated
|
||||
- **Action for planner:** Use `BulletCell` pattern (split on `\n`, render `<ul>/<li>`)
|
||||
|
||||
2. **`columns-3` icon availability — RESOLVED**
|
||||
- Verified: `Columns3` is present in lucide-react ^0.577.0 installed package
|
||||
- Use `<LucideIcon name="columns-3" size={16} />` — the LucideIcon helper converts to PascalCase
|
||||
- `table-2` is also present as a backup if needed
|
||||
|
||||
3. **"Add Candidate" button in compare mode — RECOMMENDATION**
|
||||
- Currently guarded by `{isActive && ...}` in `$threadId.tsx`
|
||||
- Recommendation: hide "Add Candidate" when `candidateViewMode === "compare"` (keep toolbar uncluttered; users switch to list/grid to add)
|
||||
- Implementation: add `&& candidateViewMode !== "compare"` to the existing `isActive` guard
|
||||
|
||||
---
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Bun test (built-in) |
|
||||
| Config file | None — uses `bun test` directly |
|
||||
| Quick run command | `bun test tests/lib/` |
|
||||
| Full suite command | `bun test` |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| COMP-01 | Candidates display all required fields in table | manual-only (UI/browser) | — | N/A |
|
||||
| COMP-02 | Delta computation: null-safe, best candidate identified, zero-delta suppressed | unit (if extracted to util) | `bun test tests/lib/comparison-deltas.test.ts` | ❌ Wave 0 gap (optional) |
|
||||
| COMP-03 | Table scrolls horizontally / sticky label column stays fixed | manual-only (CSS/browser) | — | N/A |
|
||||
| COMP-04 | Resolved thread shows read-only view with winner marked; no zero for missing data | manual-only (UI state) | — | N/A |
|
||||
|
||||
**Note on testing scope:** COMP-01, COMP-03, COMP-04 are UI/browser behaviors. COMP-02 delta logic is pure arithmetic — testable if extracted to a standalone utility function. This is a pure frontend phase; the existing `bun test` suite covers backend services only and will not be broken by this phase.
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** `bun test` (full suite, fast — no UI tests in suite)
|
||||
- **Per wave merge:** `bun test`
|
||||
- **Phase gate:** Full suite green + manual browser verification of scroll/sticky behavior on a narrow viewport
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `tests/lib/comparison-deltas.test.ts` — covers COMP-02 delta logic if extracted to a pure utility (optional; skip if deltas stay inlined in the React component)
|
||||
|
||||
*(If delta computation stays in the React component via `useMemo`, no new test files are needed — COMP-02 is verified manually in the browser.)*
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- Project codebase direct inspection:
|
||||
- `src/client/stores/uiStore.ts` — confirmed `candidateViewMode` type and setter
|
||||
- `src/client/routes/threads/$threadId.tsx` — confirmed integration points, toggle bar pattern, lines to modify
|
||||
- `src/client/components/CandidateListItem.tsx` — confirmed `RankBadge` export, `CandidateWithCategory` interface
|
||||
- `src/client/components/CandidateCard.tsx` — confirmed field usage patterns
|
||||
- `src/client/components/CandidateForm.tsx` — confirmed pros/cons are newline-separated textarea input; empty = null
|
||||
- `src/client/lib/formatters.ts` — confirmed null handling, `"--"` return for null
|
||||
- `src/client/hooks/useThreads.ts` — confirmed `CandidateWithCategory` shape with all fields needed
|
||||
- `package.json` — confirmed no new dependencies needed
|
||||
- Runtime verification: `Columns3` in lucide-react ^0.577.0 confirmed present via node script
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- [Tailwind CSS overflow docs](https://tailwindcss.com/docs/overflow) — `overflow-x-auto` on wrapper div pattern
|
||||
- [Tailwind CSS position docs](https://tailwindcss.com/docs/position) — sticky utility, z-index behavior
|
||||
- [Lexington Themes — scrollable sticky header table](https://lexingtonthemes.com/blog/how-to-build-a-scrollable-table-with-sticky-header-using-tailwind-css) — confirmed sticky thead pattern with Tailwind
|
||||
|
||||
### Tertiary (LOW confidence — WebSearch, not verified against official spec)
|
||||
- [Multi-Directional Sticky CSS (Medium, Jan 2026)](https://medium.com/@ashutoshgautam10b11/multi-directional-sticky-css-and-horizontal-scroll-in-tables-41fc25c3ce8b) — z-index layering reference; this phase only needs one sticky axis (left column, z-10 suffices)
|
||||
- [DEV Community — sticky frozen column](https://dev.to/nicolaserny/table-with-a-fixed-first-column-2c5b) — background color requirement for sticky cells confirmed
|
||||
- [freeCodeCamp Forum — overflow + sticky](https://forum.freecodecamp.org/t/fixing-sticky-table-header-with-horizontal-scroll-in-a-scrollable-container/735559) — overflow-x-auto must be on wrapper div, not table element
|
||||
|
||||
---
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH — all dependencies confirmed present in package.json; no new packages
|
||||
- Architecture: HIGH — directly derived from reading all relevant project source files
|
||||
- Pitfalls: HIGH — sticky bg issue confirmed by multiple sources; overflow-on-table confirmed by CSS spec; pros/cons newline format confirmed from CandidateForm source
|
||||
- Delta computation: HIGH — pure arithmetic, formatters already handle null, confirmed return values
|
||||
|
||||
**Research date:** 2026-03-17
|
||||
**Valid until:** 2026-04-17 (stable CSS, stable React, stable project codebase)
|
||||
78
.planning/phases/12-comparison-view/12-VALIDATION.md
Normal file
78
.planning/phases/12-comparison-view/12-VALIDATION.md
Normal file
@@ -0,0 +1,78 @@
|
||||
---
|
||||
phase: 12
|
||||
slug: comparison-view
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: 2026-03-17
|
||||
---
|
||||
|
||||
# Phase 12 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | Bun test (built-in) |
|
||||
| **Config file** | None — uses `bun test` directly |
|
||||
| **Quick run command** | `bun test` |
|
||||
| **Full suite command** | `bun test` |
|
||||
| **Estimated runtime** | ~5 seconds |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run `bun test`
|
||||
- **After every plan wave:** Run `bun test`
|
||||
- **Before `/gsd:verify-work`:** Full suite must be green
|
||||
- **Max feedback latency:** 5 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
|
||||
| 12-01-01 | 01 | 1 | COMP-01 | manual-only (UI/browser) | — | N/A | ⬜ pending |
|
||||
| 12-01-02 | 01 | 1 | COMP-02 | unit (if delta util extracted) | `bun test tests/lib/comparison-deltas.test.ts` | ❌ W0 | ⬜ pending |
|
||||
| 12-01-03 | 01 | 1 | COMP-03 | manual-only (CSS/browser) | — | N/A | ⬜ pending |
|
||||
| 12-01-04 | 01 | 1 | COMP-04 | manual-only (UI state) | — | N/A | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `tests/lib/comparison-deltas.test.ts` — stubs for COMP-02 delta computation (optional; skip if deltas stay inlined in React component via useMemo)
|
||||
|
||||
*Existing `bun test` infrastructure covers all backend services. This phase is pure frontend — no backend tests are broken.*
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Candidates display all fields in tabular columns | COMP-01 | UI rendering — no backend logic | Open thread with 2+ candidates, toggle compare view, verify all fields visible |
|
||||
| Table scrolls horizontally with sticky label column | COMP-03 | CSS behavior — requires browser | Narrow viewport to <768px, verify horizontal scroll, verify label column stays fixed |
|
||||
| Resolved thread shows read-only view with winner marked | COMP-04 | UI state — requires resolved thread | Open resolved thread, toggle compare view, verify winner column highlighted, no interactive elements |
|
||||
| Missing weight/price shows dash, not zero | COMP-04 | UI rendering for null data | Add candidate with no weight, toggle compare, verify "—" not "0g" |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 5s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
95
.planning/phases/12-comparison-view/12-VERIFICATION.md
Normal file
95
.planning/phases/12-comparison-view/12-VERIFICATION.md
Normal file
@@ -0,0 +1,95 @@
|
||||
---
|
||||
phase: 12-comparison-view
|
||||
verified: 2026-03-17T00:00:00Z
|
||||
status: passed
|
||||
score: 7/7 must-haves verified
|
||||
re_verification: false
|
||||
---
|
||||
|
||||
# Phase 12: Comparison View Verification Report
|
||||
|
||||
**Phase Goal:** Users can view all candidates for a thread side-by-side in a table with relative weight and price deltas
|
||||
**Verified:** 2026-03-17
|
||||
**Status:** passed
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | User can toggle to a Compare view when a thread has 2+ candidates | VERIFIED | `$threadId.tsx:172` — compare button wrapped in `{thread.candidates.length >= 2 && (...)}`; clicking calls `setCandidateViewMode("compare")` |
|
||||
| 2 | Comparison table shows all candidates side-by-side with weight, price, images, notes, links, status, pros, and cons | VERIFIED | `ComparisonTable.tsx:104-269` — ATTRIBUTE_ROWS array defines all 10 rows: Image, Name, Rank, Weight, Price, Status, Link, Notes, Pros, Cons |
|
||||
| 3 | The lightest candidate weight cell has a blue highlight; the cheapest candidate price cell has a green highlight | VERIFIED | `ComparisonTable.tsx:167` — `cellClass` returns `"bg-blue-50"` when `c.id === bestWeightId`; `ComparisonTable.tsx:193` — `"bg-green-50"` when `c.id === bestPriceId` |
|
||||
| 4 | Non-best cells show a gray +delta string; best cells show no delta | VERIFIED | `ComparisonTable.tsx:64-66` — delta stored as `null` when `delta === 0` (best), else `+${formatWeight(delta, unit)}`; `ComparisonTable.tsx:160-162` — delta div only rendered when `!isBest && delta` |
|
||||
| 5 | The table scrolls horizontally on narrow viewports while the attribute label column stays fixed on the left | VERIFIED | `ComparisonTable.tsx:274` — outer `<div className="overflow-x-auto ...">` for scroll; `ComparisonTable.tsx:282,311` — every label cell has `sticky left-0 z-10 bg-white` |
|
||||
| 6 | Missing weight or price data displays a dash, never a misleading zero | VERIFIED | `ComparisonTable.tsx:152-153` — `if (c.weightGrams == null) return <span className="text-gray-300">—</span>`; `ComparisonTable.tsx:178-179` — same pattern for price |
|
||||
| 7 | A resolved thread shows the comparison read-only with the winner column visually marked (amber tint + trophy) | VERIFIED | `ComparisonTable.tsx:284-301` — winner `<th>` gets `bg-amber-50 text-amber-800` + trophy icon; body cells get `bg-amber-50/50` tint via default `extraClass` branch at line 318-320; no mutation controls exist inside ComparisonTable |
|
||||
|
||||
**Score:** 7/7 truths verified
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `src/client/components/ComparisonTable.tsx` | Tabular side-by-side comparison component; min 120 lines | VERIFIED | 336 lines; full ATTRIBUTE_ROWS declarative pattern, useMemo deltas, sticky column, winner highlighting |
|
||||
| `src/client/stores/uiStore.ts` | Extended candidateViewMode union including "compare" | VERIFIED | Line 53: `candidateViewMode: "list" \| "grid" \| "compare"` and line 54: `setCandidateViewMode: (mode: "list" \| "grid" \| "compare") => void` |
|
||||
| `src/client/routes/threads/$threadId.tsx` | Compare toggle button and ComparisonTable rendering branch | VERIFIED | Line 6 imports ComparisonTable; line 172-185 conditionally renders compare button; line 207-211 renders `<ComparisonTable>` when `candidateViewMode === "compare"` |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|----|-----|--------|---------|
|
||||
| `$threadId.tsx` | `ComparisonTable.tsx` | import + conditional render when `candidateViewMode === "compare"` | WIRED | Line 6 imports; line 207 `candidateViewMode === "compare"` branch renders `<ComparisonTable candidates={displayItems} resolvedCandidateId={thread.resolvedCandidateId} />` |
|
||||
| `ComparisonTable.tsx` | `lib/formatters.ts` | `formatWeight` and `formatPrice` for cell values and delta strings | WIRED | Line 4 imports both; used at lines 66, 92, 158, 184 for both display values and delta string construction |
|
||||
| `ComparisonTable.tsx` | `CandidateListItem.tsx` | `RankBadge` import for rank row | WIRED | Line 7 imports `RankBadge`; used at line 144 in the rank ATTRIBUTE_ROW render function |
|
||||
| `$threadId.tsx` | `uiStore.ts` | `candidateViewMode` state read and `setCandidateViewMode` action | WIRED | Lines 24-25 read both from `useUIStore`; `candidateViewMode` read at lines 124, 152, 164, 177, 207, 212; `setCandidateViewMode` called at lines 150, 163, 175 |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|-------------|-------------|-------------|--------|----------|
|
||||
| COMP-01 | 12-01-PLAN.md | User can view candidates side-by-side in a tabular comparison layout (weight, price, images, notes, links, status) | SATISFIED | ComparisonTable renders all fields as columns; toggle wired in $threadId.tsx |
|
||||
| COMP-02 | 12-01-PLAN.md | User can see relative deltas highlighting the lightest and cheapest candidate with +/- differences | SATISFIED | useMemo delta computation at lines 47-102; blue-50/green-50 highlights; gray delta text for non-best cells |
|
||||
| COMP-03 | 12-01-PLAN.md | Comparison table scrolls horizontally with a sticky label column on narrow viewports | SATISFIED | `overflow-x-auto` on wrapper; `sticky left-0 z-10 bg-white` on all label cells; `minWidth` computed from candidate count |
|
||||
| COMP-04 | 12-01-PLAN.md | Comparison view displays read-only summary for resolved threads | SATISFIED | No mutation actions in ComparisonTable; winner column amber-marked with trophy; `resolvedCandidateId` prop drives the read-only winner state |
|
||||
|
||||
No orphaned requirements found. REQUIREMENTS.md maps COMP-01 through COMP-04 exclusively to Phase 12, all are accounted for by plan 12-01.
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| — | — | None found | — | — |
|
||||
|
||||
No TODO/FIXME/placeholder comments, empty implementations, or stub returns found in any of the three phase 12 files. Biome lint passes cleanly on all three files (only pre-existing unrelated issues in other src files; none in phase 12 files).
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
#### 1. Horizontal scroll with sticky label column
|
||||
|
||||
**Test:** Open a thread with 3+ candidates on a narrow viewport (< 600px). Scroll the table right.
|
||||
**Expected:** Candidate columns scroll off-screen left; the attribute label column (Image, Name, Rank, etc.) remains fixed at the left edge without content bleed-through.
|
||||
**Why human:** CSS `sticky` behavior with `overflow-x-auto` interactions cannot be asserted by grep; only visual browser confirmation validates the `bg-white` bleed-through prevention.
|
||||
|
||||
#### 2. Winner column amber tint + trophy on resolved thread
|
||||
|
||||
**Test:** Navigate to a thread that has been resolved. Switch to compare view.
|
||||
**Expected:** The winning candidate's column header shows a trophy icon and amber background; every row of that column has a subtle amber-50/50 tint. Weight/price highlight colors (blue/green) take priority over amber when the winner is also the lightest/cheapest.
|
||||
**Why human:** Color layering and opacity compositing require visual verification.
|
||||
|
||||
#### 3. Delta display with mixed null/non-null data
|
||||
|
||||
**Test:** Add two candidates to a thread where one has weight data and the other does not. Switch to compare view.
|
||||
**Expected:** The candidate with no weight shows an em dash in the weight row (not 0g). The one with weight shows its value with no delta label (it is trivially the best). No misleading zero appears.
|
||||
**Why human:** Edge-case rendering for the null path requires runtime React state to confirm the `formatWeight(null)` → `"--"` path is reached and displayed as `—` (em dash span), not the `"--"` string fallback from formatters.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No gaps found. All seven observable truths are verified, all three artifacts exist and are substantive, all four key links are wired end-to-end, all four COMP requirements are satisfied by traceable code, lint passes on all phase files, and 135 tests pass with zero regressions.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-03-17_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
253
.planning/phases/13-setup-impact-preview/13-01-PLAN.md
Normal file
253
.planning/phases/13-setup-impact-preview/13-01-PLAN.md
Normal file
@@ -0,0 +1,253 @@
|
||||
---
|
||||
phase: 13-setup-impact-preview
|
||||
plan: 01
|
||||
type: tdd
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- src/client/lib/impactDeltas.ts
|
||||
- src/client/hooks/useImpactDeltas.ts
|
||||
- src/client/hooks/useThreads.ts
|
||||
- src/client/stores/uiStore.ts
|
||||
- tests/lib/impactDeltas.test.ts
|
||||
autonomous: true
|
||||
requirements: [IMPC-01, IMPC-02, IMPC-03, IMPC-04]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "computeImpactDeltas returns replace-mode deltas when a setup item matches the thread category"
|
||||
- "computeImpactDeltas returns add-mode deltas (candidate value only) when no category match exists"
|
||||
- "computeImpactDeltas returns null weightDelta/priceDelta when candidate has null weight/price"
|
||||
- "computeImpactDeltas returns mode 'none' with empty deltas when setupItems is undefined"
|
||||
- "selectedSetupId state persists in uiStore and can be set/cleared"
|
||||
- "ThreadWithCandidates interface includes categoryId field"
|
||||
artifacts:
|
||||
- path: "src/client/lib/impactDeltas.ts"
|
||||
provides: "Pure computeImpactDeltas function with CandidateDelta, ImpactDeltas types"
|
||||
exports: ["computeImpactDeltas", "CandidateInput", "CandidateDelta", "DeltaMode", "ImpactDeltas"]
|
||||
- path: "src/client/hooks/useImpactDeltas.ts"
|
||||
provides: "React hook wrapping computeImpactDeltas in useMemo"
|
||||
exports: ["useImpactDeltas"]
|
||||
- path: "tests/lib/impactDeltas.test.ts"
|
||||
provides: "Unit tests for all four IMPC requirements"
|
||||
contains: "computeImpactDeltas"
|
||||
key_links:
|
||||
- from: "src/client/hooks/useImpactDeltas.ts"
|
||||
to: "src/client/lib/impactDeltas.ts"
|
||||
via: "import computeImpactDeltas"
|
||||
pattern: "import.*computeImpactDeltas.*from.*lib/impactDeltas"
|
||||
- from: "src/client/hooks/useImpactDeltas.ts"
|
||||
to: "src/client/hooks/useSetups.ts"
|
||||
via: "SetupItemWithCategory type import"
|
||||
pattern: "import.*SetupItemWithCategory.*from.*useSetups"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create the pure impact delta computation logic with full TDD coverage, add selectedSetupId to uiStore, fix the ThreadWithCandidates type to include categoryId, and wrap it all in a useImpactDeltas hook.
|
||||
|
||||
Purpose: Establish the data layer and contracts that Plan 02 will consume for rendering delta indicators. TDD ensures the replace/add mode logic and null-weight handling are correct before any UI work.
|
||||
Output: Tested pure function, React hook, updated types, uiStore state.
|
||||
</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/13-setup-impact-preview/13-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
||||
|
||||
From src/client/hooks/useSetups.ts:
|
||||
```typescript
|
||||
interface SetupItemWithCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
weightGrams: number | null;
|
||||
priceCents: number | null;
|
||||
categoryId: number;
|
||||
notes: string | null;
|
||||
productUrl: string | null;
|
||||
imageFilename: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
categoryName: string;
|
||||
categoryIcon: string;
|
||||
classification: string;
|
||||
}
|
||||
|
||||
export function useSetup(setupId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: ["setups", setupId],
|
||||
queryFn: () => apiGet<SetupWithItems>(`/api/setups/${setupId}`),
|
||||
enabled: setupId != null, // CRITICAL: prevents null fetch
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
From src/client/hooks/useThreads.ts (BEFORE fix):
|
||||
```typescript
|
||||
interface ThreadWithCandidates {
|
||||
id: number;
|
||||
name: string;
|
||||
status: "active" | "resolved";
|
||||
resolvedCandidateId: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
candidates: CandidateWithCategory[];
|
||||
// NOTE: categoryId is MISSING — server returns it but type omits it
|
||||
}
|
||||
```
|
||||
|
||||
From src/client/stores/uiStore.ts (existing pattern):
|
||||
```typescript
|
||||
candidateViewMode: "list" | "grid" | "compare";
|
||||
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;
|
||||
// Add selectedSetupId + setter using same pattern
|
||||
```
|
||||
|
||||
From src/client/lib/formatters.ts:
|
||||
```typescript
|
||||
export type WeightUnit = "g" | "oz" | "lb" | "kg";
|
||||
export function formatWeight(grams: number | null | undefined, unit: WeightUnit = "g"): string;
|
||||
export type Currency = "USD" | "EUR" | "GBP" | "JPY" | "CAD" | "AUD";
|
||||
export function formatPrice(cents: number | null | undefined, currency: Currency = "USD"): string;
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<feature>
|
||||
<name>Impact Delta Computation</name>
|
||||
<files>src/client/lib/impactDeltas.ts, tests/lib/impactDeltas.test.ts, src/client/hooks/useImpactDeltas.ts, src/client/hooks/useThreads.ts, src/client/stores/uiStore.ts</files>
|
||||
<behavior>
|
||||
IMPC-01 (setup selected, deltas computed):
|
||||
- Given candidates with weight/price and a setup with items, returns per-candidate delta objects with weightDelta and priceDelta numbers
|
||||
- Given no setup selected (setupItems = undefined), returns { mode: "none", deltas: {} }
|
||||
|
||||
IMPC-02 (replace mode auto-detection):
|
||||
- Given setup items where one has categoryId === threadCategoryId, mode is "replace"
|
||||
- In replace mode, weightDelta = candidate.weightGrams - replacedItem.weightGrams
|
||||
- In replace mode, priceDelta = candidate.priceCents - replacedItem.priceCents
|
||||
- replacedItemName is populated with the matched item's name
|
||||
|
||||
IMPC-03 (add mode):
|
||||
- Given setup items where NONE have categoryId === threadCategoryId, mode is "add"
|
||||
- In add mode, weightDelta = candidate.weightGrams (pure addition)
|
||||
- In add mode, priceDelta = candidate.priceCents (pure addition)
|
||||
- replacedItemName is null
|
||||
|
||||
IMPC-04 (null weight handling):
|
||||
- Given candidate.weightGrams is null, weightDelta is null (not 0, not NaN)
|
||||
- Given candidate.priceCents is null, priceDelta is null
|
||||
- In replace mode with replacedItem.weightGrams null but candidate has weight, weightDelta = candidate.weightGrams (treat as add for that field)
|
||||
|
||||
Edge cases:
|
||||
- Empty candidates array -> returns { mode based on setup, deltas: {} }
|
||||
- Multiple setup items in same category as thread -> first match used for replacement
|
||||
</behavior>
|
||||
<implementation>
|
||||
1. Create src/client/lib/impactDeltas.ts with:
|
||||
- CandidateInput interface: { id: number; weightGrams: number | null; priceCents: number | null }
|
||||
- DeltaMode type: "replace" | "add" | "none"
|
||||
- CandidateDelta interface: { candidateId, mode, weightDelta, priceDelta, replacedItemName }
|
||||
- ImpactDeltas interface: { mode: DeltaMode; deltas: Record<number, CandidateDelta> }
|
||||
- SetupItemInput interface: { categoryId: number; weightGrams: number | null; priceCents: number | null; name: string } (minimal subset of SetupItemWithCategory)
|
||||
- computeImpactDeltas(candidates, setupItems, threadCategoryId) pure function
|
||||
|
||||
2. Create src/client/hooks/useImpactDeltas.ts wrapping in useMemo
|
||||
|
||||
3. Add categoryId to ThreadWithCandidates in useThreads.ts
|
||||
|
||||
4. Add selectedSetupId + setSelectedSetupId to uiStore.ts
|
||||
</implementation>
|
||||
</feature>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: TDD pure computeImpactDeltas function</name>
|
||||
<files>src/client/lib/impactDeltas.ts, tests/lib/impactDeltas.test.ts</files>
|
||||
<behavior>
|
||||
- Test: no setup selected (undefined) returns mode "none", empty deltas
|
||||
- Test: replace mode — setup item matches threadCategoryId, deltas are candidate minus replaced item
|
||||
- Test: add mode — no setup item matches, deltas equal candidate values
|
||||
- Test: null candidate weight returns null weightDelta, not zero
|
||||
- Test: null candidate price returns null priceDelta
|
||||
- Test: replace mode with null replacedItem weight but valid candidate weight returns candidate weight as delta (add-like for that field)
|
||||
- Test: negative delta in replace mode (candidate lighter than replaced item)
|
||||
- Test: zero delta in replace mode (identical weight)
|
||||
- Test: replacedItemName populated in replace mode, null in add mode
|
||||
</behavior>
|
||||
<action>
|
||||
RED: Create tests/lib/impactDeltas.test.ts importing computeImpactDeltas from @/client/lib/impactDeltas. Write all test cases above using Bun test (describe/test/expect). Run tests — they MUST fail (module not found).
|
||||
|
||||
GREEN: Create src/client/lib/impactDeltas.ts with:
|
||||
- Export types: CandidateInput, SetupItemInput, DeltaMode, CandidateDelta, ImpactDeltas
|
||||
- Export function computeImpactDeltas(candidates: CandidateInput[], setupItems: SetupItemInput[] | undefined, threadCategoryId: number): ImpactDeltas
|
||||
- Logic: if !setupItems return { mode: "none", deltas: {} }
|
||||
- Find replacedItem = setupItems.find(item => item.categoryId === threadCategoryId) ?? null
|
||||
- mode = replacedItem ? "replace" : "add"
|
||||
- For each candidate: null-guard weight/price BEFORE arithmetic. In replace mode with non-null replaced value, delta = candidate - replaced. In replace mode with null replaced value, delta = candidate value (like add). In add mode, delta = candidate value.
|
||||
- Run tests — all MUST pass.
|
||||
|
||||
REFACTOR: None needed for pure function.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/lib/impactDeltas.test.ts</automated>
|
||||
</verify>
|
||||
<done>All 9+ test cases pass. computeImpactDeltas correctly handles replace mode, add mode, null weights, null prices, and edge cases. Types are exported.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Add uiStore state, fix ThreadWithCandidates type, create useImpactDeltas hook</name>
|
||||
<files>src/client/stores/uiStore.ts, src/client/hooks/useThreads.ts, src/client/hooks/useImpactDeltas.ts</files>
|
||||
<action>
|
||||
1. In src/client/stores/uiStore.ts, add to UIState interface:
|
||||
- selectedSetupId: number | null;
|
||||
- setSelectedSetupId: (id: number | null) => void;
|
||||
Add to create() initializer:
|
||||
- selectedSetupId: null,
|
||||
- setSelectedSetupId: (id) => set({ selectedSetupId: id }),
|
||||
Place after the "Candidate view mode" section as "// Setup impact preview" section.
|
||||
|
||||
2. In src/client/hooks/useThreads.ts, add `categoryId: number;` to the ThreadWithCandidates interface, after the `resolvedCandidateId` field. The server already returns this field — the type was simply missing it.
|
||||
|
||||
3. Create src/client/hooks/useImpactDeltas.ts:
|
||||
- Import useMemo from react
|
||||
- Import computeImpactDeltas and types from "../lib/impactDeltas"
|
||||
- Import type { SetupItemWithCategory } from "./useSetups"
|
||||
- Export function useImpactDeltas(candidates: CandidateInput[], setupItems: SetupItemWithCategory[] | undefined, threadCategoryId: number): ImpactDeltas
|
||||
- Body: return useMemo(() => computeImpactDeltas(candidates, setupItems, threadCategoryId), [candidates, setupItems, threadCategoryId])
|
||||
- Re-export CandidateDelta, DeltaMode, ImpactDeltas types for convenience
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/lib/impactDeltas.test.ts && bun test tests/lib/formatters.test.ts</automated>
|
||||
</verify>
|
||||
<done>uiStore has selectedSetupId + setter. ThreadWithCandidates includes categoryId. useImpactDeltas hook created and exports types. All existing tests still pass.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `bun test tests/lib/` passes all tests (impactDeltas + formatters)
|
||||
- `bun test` full suite passes (no regressions)
|
||||
- Types export correctly: CandidateInput, CandidateDelta, DeltaMode, ImpactDeltas, SetupItemInput from impactDeltas.ts
|
||||
- useImpactDeltas hook wraps pure function in useMemo
|
||||
- uiStore.selectedSetupId defaults to null
|
||||
- ThreadWithCandidates.categoryId is declared as number
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All IMPC requirement behaviors are tested and passing via pure function unit tests
|
||||
- Data layer contracts (types, hook, uiStore state) are ready for Plan 02 UI consumption
|
||||
- Zero regressions in existing test suite
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/13-setup-impact-preview/13-01-SUMMARY.md`
|
||||
</output>
|
||||
330
.planning/phases/13-setup-impact-preview/13-02-PLAN.md
Normal file
330
.planning/phases/13-setup-impact-preview/13-02-PLAN.md
Normal file
@@ -0,0 +1,330 @@
|
||||
---
|
||||
phase: 13-setup-impact-preview
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["13-01"]
|
||||
files_modified:
|
||||
- src/client/components/SetupImpactSelector.tsx
|
||||
- src/client/components/ImpactDeltaBadge.tsx
|
||||
- src/client/routes/threads/$threadId.tsx
|
||||
- src/client/components/CandidateListItem.tsx
|
||||
- src/client/components/CandidateCard.tsx
|
||||
- src/client/components/ComparisonTable.tsx
|
||||
autonomous: true
|
||||
requirements: [IMPC-01, IMPC-02, IMPC-03, IMPC-04]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "User can select a setup from a dropdown in the thread header"
|
||||
- "Each candidate displays weight and cost delta badges when a setup is selected"
|
||||
- "Replace mode shows signed delta with replaced item name context"
|
||||
- "Add mode shows positive delta labeled as '(add)'"
|
||||
- "Candidate with no weight shows '-- (no weight data)' instead of a zero"
|
||||
- "Candidate with no price shows '-- (no price data)' instead of a zero"
|
||||
- "Deselecting setup ('None') clears all delta indicators"
|
||||
- "Deltas appear in list view, grid view, and comparison table"
|
||||
artifacts:
|
||||
- path: "src/client/components/SetupImpactSelector.tsx"
|
||||
provides: "Setup dropdown for thread header"
|
||||
exports: ["SetupImpactSelector"]
|
||||
- path: "src/client/components/ImpactDeltaBadge.tsx"
|
||||
provides: "Inline delta indicator component"
|
||||
exports: ["ImpactDeltaBadge"]
|
||||
- path: "src/client/routes/threads/$threadId.tsx"
|
||||
provides: "Thread detail page wired with impact preview"
|
||||
- path: "src/client/components/CandidateListItem.tsx"
|
||||
provides: "List item with delta badges"
|
||||
- path: "src/client/components/CandidateCard.tsx"
|
||||
provides: "Card with delta badges"
|
||||
- path: "src/client/components/ComparisonTable.tsx"
|
||||
provides: "Comparison table with impact delta rows"
|
||||
key_links:
|
||||
- from: "src/client/routes/threads/$threadId.tsx"
|
||||
to: "src/client/hooks/useImpactDeltas.ts"
|
||||
via: "useImpactDeltas hook call at page level"
|
||||
pattern: "useImpactDeltas"
|
||||
- from: "src/client/routes/threads/$threadId.tsx"
|
||||
to: "src/client/hooks/useSetups.ts"
|
||||
via: "useSetup(selectedSetupId) for setup item data"
|
||||
pattern: "useSetup\\(selectedSetupId"
|
||||
- from: "src/client/routes/threads/$threadId.tsx"
|
||||
to: "src/client/stores/uiStore.ts"
|
||||
via: "selectedSetupId state read"
|
||||
pattern: "useUIStore.*selectedSetupId"
|
||||
- from: "src/client/components/SetupImpactSelector.tsx"
|
||||
to: "src/client/stores/uiStore.ts"
|
||||
via: "setSelectedSetupId state write"
|
||||
pattern: "setSelectedSetupId"
|
||||
- from: "src/client/components/ImpactDeltaBadge.tsx"
|
||||
to: "src/client/lib/impactDeltas.ts"
|
||||
via: "CandidateDelta type import"
|
||||
pattern: "import.*CandidateDelta"
|
||||
- from: "src/client/components/ComparisonTable.tsx"
|
||||
to: "src/client/lib/impactDeltas.ts"
|
||||
via: "ImpactDeltas type for deltas prop"
|
||||
pattern: "import.*ImpactDeltas"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the UI components (setup dropdown + delta badges) and wire them into the thread detail page across all three view modes (list, grid, compare).
|
||||
|
||||
Purpose: This is the user-facing delivery of the impact preview feature. Plan 01 built the logic; this plan renders it.
|
||||
Output: SetupImpactSelector component, ImpactDeltaBadge component, updated CandidateListItem/CandidateCard/ComparisonTable with delta rendering, wired thread detail page.
|
||||
</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/13-setup-impact-preview/13-RESEARCH.md
|
||||
@.planning/phases/13-setup-impact-preview/13-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Types created by Plan 01 that this plan consumes -->
|
||||
|
||||
From src/client/lib/impactDeltas.ts (created in Plan 01):
|
||||
```typescript
|
||||
export interface CandidateInput {
|
||||
id: number;
|
||||
weightGrams: number | null;
|
||||
priceCents: number | null;
|
||||
}
|
||||
|
||||
export type DeltaMode = "replace" | "add" | "none";
|
||||
|
||||
export interface CandidateDelta {
|
||||
candidateId: number;
|
||||
mode: DeltaMode;
|
||||
weightDelta: number | null;
|
||||
priceDelta: number | null;
|
||||
replacedItemName: string | null;
|
||||
}
|
||||
|
||||
export interface ImpactDeltas {
|
||||
mode: DeltaMode;
|
||||
deltas: Record<number, CandidateDelta>;
|
||||
}
|
||||
```
|
||||
|
||||
From src/client/hooks/useImpactDeltas.ts (created in Plan 01):
|
||||
```typescript
|
||||
export function useImpactDeltas(
|
||||
candidates: CandidateInput[],
|
||||
setupItems: SetupItemWithCategory[] | undefined,
|
||||
threadCategoryId: number,
|
||||
): ImpactDeltas;
|
||||
```
|
||||
|
||||
From src/client/hooks/useSetups.ts:
|
||||
```typescript
|
||||
export function useSetups(): UseQueryResult<SetupListItem[]>;
|
||||
export function useSetup(setupId: number | null): UseQueryResult<SetupWithItems>;
|
||||
```
|
||||
|
||||
From src/client/stores/uiStore.ts (updated in Plan 01):
|
||||
```typescript
|
||||
selectedSetupId: number | null;
|
||||
setSelectedSetupId: (id: number | null) => void;
|
||||
```
|
||||
|
||||
From src/client/hooks/useThreads.ts (updated in Plan 01):
|
||||
```typescript
|
||||
interface ThreadWithCandidates {
|
||||
id: number;
|
||||
name: string;
|
||||
status: "active" | "resolved";
|
||||
resolvedCandidateId: number | null;
|
||||
categoryId: number; // <-- Added in Plan 01
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
candidates: CandidateWithCategory[];
|
||||
}
|
||||
```
|
||||
|
||||
From src/client/lib/formatters.ts:
|
||||
```typescript
|
||||
export function formatWeight(grams: number | null | undefined, unit: WeightUnit = "g"): string;
|
||||
export function formatPrice(cents: number | null | undefined, currency: Currency = "USD"): string;
|
||||
```
|
||||
|
||||
Existing component props that need delta additions:
|
||||
|
||||
CandidateListItem props:
|
||||
```typescript
|
||||
interface CandidateListItemProps {
|
||||
candidate: CandidateWithCategory;
|
||||
rank: number;
|
||||
isActive: boolean;
|
||||
onStatusChange: (status: "researching" | "ordered" | "arrived") => void;
|
||||
// Will add: delta?: CandidateDelta;
|
||||
}
|
||||
```
|
||||
|
||||
CandidateCard props:
|
||||
```typescript
|
||||
interface CandidateCardProps {
|
||||
id: number;
|
||||
name: string;
|
||||
weightGrams: number | null;
|
||||
priceCents: number | null;
|
||||
// ... other props
|
||||
// Will add: delta?: CandidateDelta;
|
||||
}
|
||||
```
|
||||
|
||||
ComparisonTable props:
|
||||
```typescript
|
||||
interface ComparisonTableProps {
|
||||
candidates: CandidateWithCategory[];
|
||||
resolvedCandidateId: number | null;
|
||||
// Will add: deltas?: Record<number, CandidateDelta>;
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create SetupImpactSelector and ImpactDeltaBadge components</name>
|
||||
<files>src/client/components/SetupImpactSelector.tsx, src/client/components/ImpactDeltaBadge.tsx</files>
|
||||
<action>
|
||||
1. Create src/client/components/SetupImpactSelector.tsx:
|
||||
- Import useSetups from hooks/useSetups
|
||||
- Import useUIStore from stores/uiStore
|
||||
- Export function SetupImpactSelector()
|
||||
- Read selectedSetupId and setSelectedSetupId from uiStore
|
||||
- Fetch setups via useSetups()
|
||||
- If no setups or loading, return null
|
||||
- Render: a flex row with label "Impact on setup:" (text-xs text-gray-500) and a native `<select>` element
|
||||
- Select value = selectedSetupId ?? "", onChange parses to number or null
|
||||
- Options: "None" (value="") + each setup by name
|
||||
- Styling: text-sm border border-gray-200 rounded-lg px-2 py-1 text-gray-700 bg-white focus:outline-none focus:ring-1 focus:ring-gray-300
|
||||
|
||||
2. Create src/client/components/ImpactDeltaBadge.tsx:
|
||||
- Import type CandidateDelta from lib/impactDeltas
|
||||
- Import formatWeight, formatPrice, WeightUnit, Currency from lib/formatters
|
||||
- Import useWeightUnit from hooks/useWeightUnit
|
||||
- Import useCurrency from hooks/useCurrency
|
||||
- Export function ImpactDeltaBadge({ delta, type }: { delta: CandidateDelta | undefined; type: "weight" | "price" })
|
||||
- If !delta or delta.mode === "none", return null
|
||||
- Pick value: type === "weight" ? delta.weightDelta : delta.priceDelta
|
||||
- If value === null (no data): render `<span className="text-xs text-gray-300">` with "-- (no weight data)" or "-- (no price data)" depending on type
|
||||
- If value is a number:
|
||||
- formatted = type === "weight" ? formatWeight(Math.abs(value), unit) : formatPrice(Math.abs(value), currency)
|
||||
- sign: value > 0 -> "+" , value < 0 -> "-" (use minus sign), value === 0 -> +/-
|
||||
- colorClass: value < 0 -> "text-green-600" (lighter/cheaper is good), value > 0 -> "text-red-500", value === 0 -> "text-gray-400"
|
||||
- modeLabel: delta.mode === "add" ? " (add)" : ""
|
||||
- vsLabel: delta.mode === "replace" && delta.replacedItemName ? ` vs ${delta.replacedItemName}` : "" (only show this as a title attribute on the span, not inline text -- too long)
|
||||
- Render: `<span className="text-xs font-medium {colorClass}" title={vsLabel || undefined}>{sign}{formatted}{modeLabel}</span>`
|
||||
- The component reads unit/currency internally via hooks so callers don't need to pass them.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun run lint 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<done>SetupImpactSelector renders a setup dropdown reading from uiStore. ImpactDeltaBadge renders signed, colored delta indicators with null-data handling. Both components lint-clean.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Wire impact preview into thread detail page and all candidate views</name>
|
||||
<files>src/client/routes/threads/$threadId.tsx, src/client/components/CandidateListItem.tsx, src/client/components/CandidateCard.tsx, src/client/components/ComparisonTable.tsx</files>
|
||||
<action>
|
||||
1. In src/client/routes/threads/$threadId.tsx:
|
||||
- Add imports: useSetup from hooks/useSetups, useImpactDeltas from hooks/useImpactDeltas, SetupImpactSelector from components/SetupImpactSelector, type CandidateDelta from lib/impactDeltas
|
||||
- Read selectedSetupId from useUIStore: `const selectedSetupId = useUIStore((s) => s.selectedSetupId);`
|
||||
- Fetch setup data: `const { data: setupData } = useSetup(selectedSetupId ?? null);`
|
||||
- Compute deltas: `const impactDeltas = useImpactDeltas(thread.candidates, setupData?.items, thread.categoryId);` (place after thread is loaded, inside the render body after the isLoading/isError guards)
|
||||
- Place `<SetupImpactSelector />` in the header section, after the thread name/status row and before the toolbar. Wrap it in a div for spacing if needed.
|
||||
- Pass delta to CandidateListItem: add prop `delta={impactDeltas.deltas[candidate.id]}` to each CandidateListItem (both Reorder.Group and static div renderings)
|
||||
- Pass delta to CandidateCard: add prop `delta={impactDeltas.deltas[candidate.id]}` (the CandidateCard receives individual props, so pass it as `delta={impactDeltas.deltas[candidate.id]}`)
|
||||
- Pass deltas to ComparisonTable: add prop `deltas={impactDeltas.deltas}` alongside existing candidates and resolvedCandidateId
|
||||
|
||||
2. In src/client/components/CandidateListItem.tsx:
|
||||
- Import type CandidateDelta from lib/impactDeltas
|
||||
- Import ImpactDeltaBadge from ./ImpactDeltaBadge
|
||||
- Add `delta?: CandidateDelta;` to CandidateListItemProps
|
||||
- Add `delta` to destructured props
|
||||
- Render two ImpactDeltaBadge components inside the badges flex-wrap div (after the existing weight and price badges):
|
||||
- `{delta && <ImpactDeltaBadge delta={delta} type="weight" />}`
|
||||
- `{delta && <ImpactDeltaBadge delta={delta} type="price" />}`
|
||||
- Place these AFTER the existing weight/price badges so they appear as secondary indicators
|
||||
|
||||
3. In src/client/components/CandidateCard.tsx:
|
||||
- Import type CandidateDelta from lib/impactDeltas
|
||||
- Import ImpactDeltaBadge from ./ImpactDeltaBadge
|
||||
- Add `delta?: CandidateDelta;` to CandidateCardProps
|
||||
- Add `delta` to destructured props
|
||||
- Render two ImpactDeltaBadge components inside the badges flex-wrap div (after weight/price badges):
|
||||
- `{delta && <ImpactDeltaBadge delta={delta} type="weight" />}`
|
||||
- `{delta && <ImpactDeltaBadge delta={delta} type="price" />}`
|
||||
|
||||
4. In src/client/components/ComparisonTable.tsx:
|
||||
- Import type CandidateDelta from lib/impactDeltas
|
||||
- Import ImpactDeltaBadge from ./ImpactDeltaBadge
|
||||
- Add `deltas?: Record<number, CandidateDelta>;` to ComparisonTableProps
|
||||
- Add `deltas` to destructured props
|
||||
- Add two new rows to ATTRIBUTE_ROWS array, placed right after the "weight" row and "price" row respectively:
|
||||
a. After "weight" row, add:
|
||||
```
|
||||
{
|
||||
key: "impact-weight",
|
||||
label: "Impact (wt)",
|
||||
render: (c) => deltas?.[c.id] ? <ImpactDeltaBadge delta={deltas[c.id]} type="weight" /> : <span className="text-gray-300">--</span>,
|
||||
}
|
||||
```
|
||||
b. After "price" row, add:
|
||||
```
|
||||
{
|
||||
key: "impact-price",
|
||||
label: "Impact ($)",
|
||||
render: (c) => deltas?.[c.id] ? <ImpactDeltaBadge delta={deltas[c.id]} type="price" /> : <span className="text-gray-300">--</span>,
|
||||
}
|
||||
```
|
||||
- These are separate rows (per research recommendation) to avoid conflating candidate-relative deltas with setup impact deltas.
|
||||
- The impact rows show "--" when no setup is selected (deltas undefined or no entry for candidate).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test && bun run lint 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- SetupImpactSelector dropdown visible in thread header
|
||||
- Selecting a setup shows weight/cost delta badges on each candidate in list, grid, and compare views
|
||||
- Replace mode: signed delta with green (lighter/cheaper) or red (heavier/pricier) coloring
|
||||
- Add mode: positive delta with "(add)" label
|
||||
- Null weight/price: shows "-- (no weight data)" / "-- (no price data)" indicator
|
||||
- Deselecting setup clears all delta indicators
|
||||
- ComparisonTable has dedicated "Impact (wt)" and "Impact ($)" rows
|
||||
- All tests pass, lint clean
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `bun test` full suite passes
|
||||
- `bun run lint` clean
|
||||
- SetupImpactSelector renders in thread header with all setups as options
|
||||
- Selecting a setup triggers useSetup fetch and delta computation
|
||||
- CandidateListItem, CandidateCard, ComparisonTable all render delta badges
|
||||
- Replace mode detected when setup has item in same category as thread
|
||||
- Add mode used otherwise
|
||||
- Null weight/price shows clear indicator
|
||||
- Deselecting shows no deltas (clean state)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All four IMPC requirements visible in the UI
|
||||
- Delta rendering works across list, grid, and compare views
|
||||
- No regressions in existing functionality
|
||||
- Clean lint output
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/13-setup-impact-preview/13-02-SUMMARY.md`
|
||||
</output>
|
||||
518
.planning/phases/13-setup-impact-preview/13-RESEARCH.md
Normal file
518
.planning/phases/13-setup-impact-preview/13-RESEARCH.md
Normal file
@@ -0,0 +1,518 @@
|
||||
# Phase 13: Setup Impact Preview - Research
|
||||
|
||||
**Researched:** 2026-03-17
|
||||
**Domain:** Pure frontend — delta computation + UI (React, Zustand, React Query)
|
||||
**Confidence:** HIGH
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 13 adds a setup-selector dropdown to the thread detail header. When a user picks a setup, every candidate card and list row gains two delta indicators: weight delta and cost delta. The computation has two modes determined at render time — replace mode (a setup item exists in the same category as the thread) and add mode (no category match). The entire feature is a pure frontend addition: all required data is already available through existing hooks with zero backend or schema changes needed.
|
||||
|
||||
The delta logic is straightforward arithmetic over nullable numbers: `candidate.weightGrams - replacedItem.weightGrams` in replace mode, or `candidate.weightGrams` in add mode. The only real complexity is the null-weight indicator (IMPC-04) and driving the selected setup ID through Zustand state so it persists across view-mode switches within the same thread session.
|
||||
|
||||
**Primary recommendation:** Add `selectedSetupId: number | null` to uiStore, render a setup dropdown in the thread header, compute deltas in a `useMemo` inside a new `useImpactDeltas` hook, and render inline delta indicators below the candidate weight/price badges in both list and grid views.
|
||||
|
||||
---
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|-----------------|
|
||||
| IMPC-01 | User can select a setup and see weight and cost delta for each candidate | `useSetups()` returns all setups for dropdown; `useSetup(id)` returns items with categoryId for matching; delta computed in useMemo |
|
||||
| IMPC-02 | Impact preview auto-detects replace mode when a setup item exists in the same category as the thread | Thread has `categoryId` (from `threads.categoryId`); setup items have `categoryId` via join; match on `categoryId` equality |
|
||||
| IMPC-03 | Impact preview shows add mode (pure addition) when no category match exists in the selected setup | Default when no setup item matches `thread.categoryId`; label clearly as "+add" |
|
||||
| IMPC-04 | Candidates with missing weight data show a clear indicator instead of misleading zero deltas | `candidate.weightGrams == null` → render `"-- (no weight data)"` instead of computing |
|
||||
</phase_requirements>
|
||||
|
||||
---
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| React 19 | ^19.2.4 | UI rendering | Project foundation |
|
||||
| Zustand | ^5.0.11 | `selectedSetupId` UI state | Established pattern for all UI-only state (panel open/close, view mode) |
|
||||
| TanStack React Query | ^5.90.21 | `useSetup(id)` for setup items | Established data fetching pattern |
|
||||
| Tailwind CSS v4 | ^4.2.1 | Delta badge styling | Project styling system |
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| framer-motion | ^12.37.0 | Optional entrance animation for delta indicators | Already installed; use AnimatePresence if subtle fade needed |
|
||||
| lucide-react | ^0.577.0 | Dropdown chevron icon, delta arrow icons | Project icon system |
|
||||
|
||||
### No New Dependencies
|
||||
This phase requires zero new npm dependencies. All needed libraries are installed.
|
||||
|
||||
**Installation:**
|
||||
```bash
|
||||
# No new packages needed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Recommended Project Structure
|
||||
```
|
||||
src/client/
|
||||
├── stores/uiStore.ts # Add selectedSetupId: number | null + setter
|
||||
├── hooks/
|
||||
│ └── useImpactDeltas.ts # New: compute add/replace deltas per candidate
|
||||
├── components/
|
||||
│ ├── SetupImpactSelector.tsx # New: setup dropdown rendered in thread header
|
||||
│ └── ImpactDeltaBadge.tsx # New (or inline): weight/cost delta pill
|
||||
└── routes/threads/$threadId.tsx # Add selector to header, pass deltas down
|
||||
```
|
||||
|
||||
### Pattern 1: selectedSetupId in Zustand
|
||||
|
||||
**What:** Store selected setup ID as UI state in `uiStore.ts`, not as URL state or server state.
|
||||
|
||||
**When to use:** The selection is ephemeral per session (no permalink needed), needs to survive view-mode switches (list/grid/compare), and must be accessible from any component in the thread page without prop drilling.
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// In uiStore.ts — add to UIState interface
|
||||
selectedSetupId: number | null;
|
||||
setSelectedSetupId: (id: number | null) => void;
|
||||
|
||||
// In create() initializer
|
||||
selectedSetupId: null,
|
||||
setSelectedSetupId: (id) => set({ selectedSetupId: id }),
|
||||
```
|
||||
|
||||
### Pattern 2: useImpactDeltas Hook
|
||||
|
||||
**What:** A pure computation hook that accepts candidates + a setup's item list + the thread's categoryId, and returns per-candidate delta objects.
|
||||
|
||||
**When to use:** Delta computation must run in a single place so list, grid, and compare views all show consistent numbers.
|
||||
|
||||
**Interface:**
|
||||
```typescript
|
||||
// src/client/hooks/useImpactDeltas.ts
|
||||
import type { SetupItemWithCategory } from "./useSetups";
|
||||
|
||||
interface CandidateInput {
|
||||
id: number;
|
||||
weightGrams: number | null;
|
||||
priceCents: number | null;
|
||||
}
|
||||
|
||||
type DeltaMode = "replace" | "add" | "none"; // "none" = no setup selected
|
||||
|
||||
interface CandidateDelta {
|
||||
candidateId: number;
|
||||
mode: DeltaMode;
|
||||
weightDelta: number | null; // null = candidate has no weight data
|
||||
priceDelta: number | null; // null = candidate has no price data
|
||||
replacedItemName: string | null; // populated in replace mode for tooltip
|
||||
}
|
||||
|
||||
interface ImpactDeltas {
|
||||
mode: DeltaMode;
|
||||
deltas: Record<number, CandidateDelta>;
|
||||
}
|
||||
|
||||
export function useImpactDeltas(
|
||||
candidates: CandidateInput[],
|
||||
setupItems: SetupItemWithCategory[] | undefined,
|
||||
threadCategoryId: number,
|
||||
): ImpactDeltas
|
||||
```
|
||||
|
||||
**Logic:**
|
||||
```typescript
|
||||
// Source: project codebase pattern — mirrors ComparisonTable useMemo
|
||||
const impactDeltas = useMemo(() => {
|
||||
if (!setupItems) return { mode: "none", deltas: {} };
|
||||
|
||||
// Find replaced item: setup item whose categoryId matches thread's categoryId
|
||||
const replacedItem = setupItems.find(
|
||||
(item) => item.categoryId === threadCategoryId
|
||||
) ?? null;
|
||||
|
||||
const mode: DeltaMode = replacedItem ? "replace" : "add";
|
||||
|
||||
const deltas: Record<number, CandidateDelta> = {};
|
||||
for (const c of candidates) {
|
||||
let weightDelta: number | null = null;
|
||||
let priceDelta: number | null = null;
|
||||
|
||||
if (c.weightGrams != null) {
|
||||
weightDelta = mode === "replace" && replacedItem?.weightGrams != null
|
||||
? c.weightGrams - replacedItem.weightGrams
|
||||
: c.weightGrams;
|
||||
}
|
||||
// priceCents is integer (cents), same arithmetic
|
||||
if (c.priceCents != null) {
|
||||
priceDelta = mode === "replace" && replacedItem?.priceCents != null
|
||||
? c.priceCents - replacedItem.priceCents
|
||||
: c.priceCents;
|
||||
}
|
||||
|
||||
deltas[c.id] = {
|
||||
candidateId: c.id,
|
||||
mode,
|
||||
weightDelta,
|
||||
priceDelta,
|
||||
replacedItemName: replacedItem?.name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return { mode, deltas };
|
||||
}, [candidates, setupItems, threadCategoryId]);
|
||||
```
|
||||
|
||||
### Pattern 3: SetupImpactSelector Component
|
||||
|
||||
**What:** A compact `<select>` dropdown in the thread detail header, rendered between the thread title and the toolbar.
|
||||
|
||||
**When to use:** Always present on active and resolved thread pages (impact preview is read-only, no mutation side effects).
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// Placed in thread header, after thread name row
|
||||
function SetupImpactSelector() {
|
||||
const { data: setups } = useSetups();
|
||||
const selectedSetupId = useUIStore((s) => s.selectedSetupId);
|
||||
const setSelectedSetupId = useUIStore((s) => s.setSelectedSetupId);
|
||||
|
||||
if (!setups || setups.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-gray-500 whitespace-nowrap">
|
||||
Impact on setup:
|
||||
</label>
|
||||
<select
|
||||
value={selectedSetupId ?? ""}
|
||||
onChange={(e) => setSelectedSetupId(e.target.value ? Number(e.target.value) : null)}
|
||||
className="text-sm border border-gray-200 rounded-lg px-2 py-1 text-gray-700 bg-white focus:outline-none focus:ring-1 focus:ring-gray-300"
|
||||
>
|
||||
<option value="">None</option>
|
||||
{setups.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: ImpactDeltaBadge Rendering
|
||||
|
||||
**What:** Small inline indicator rendered below weight/price badges on each candidate. Three rendering cases per field:
|
||||
|
||||
| Case | Render |
|
||||
|------|--------|
|
||||
| No setup selected | Nothing (no change to existing layout) |
|
||||
| Candidate has no weight | `"-- (no weight data)"` in muted gray |
|
||||
| Weight exists, replace mode | `"±Xg vs [ItemName]"` with sign-colored text |
|
||||
| Weight exists, add mode | `"+Xg (add)"` in gray |
|
||||
|
||||
**Where it renders:** Below the existing `formatWeight` / `formatPrice` badges in `CandidateListItem` and `CandidateCard`. In `ComparisonTable`, can be added as a sub-row or a second line within the weight/price cells.
|
||||
|
||||
**Sign coloring convention:**
|
||||
- Negative delta (lighter/cheaper when replacing) → green text
|
||||
- Positive delta (heavier/more expensive) → red text
|
||||
- Zero delta → gray text
|
||||
- No weight data → muted gray, em-dash prefix
|
||||
|
||||
```typescript
|
||||
// Reusable inline component
|
||||
function ImpactDeltaBadge({
|
||||
delta,
|
||||
noDataLabel = "-- (no weight data)",
|
||||
unit,
|
||||
currency,
|
||||
type,
|
||||
}: {
|
||||
delta: CandidateDelta | undefined;
|
||||
noDataLabel?: string;
|
||||
unit?: WeightUnit;
|
||||
currency?: Currency;
|
||||
type: "weight" | "price";
|
||||
}) {
|
||||
if (!delta || delta.mode === "none") return null;
|
||||
|
||||
const value = type === "weight" ? delta.weightDelta : delta.priceDelta;
|
||||
|
||||
if (value === null) {
|
||||
// Candidate has no data for this field
|
||||
return (
|
||||
<span className="text-xs text-gray-300">{noDataLabel}</span>
|
||||
);
|
||||
}
|
||||
|
||||
const formatted = type === "weight"
|
||||
? formatWeight(Math.abs(value), unit)
|
||||
: formatPrice(Math.abs(value), currency);
|
||||
|
||||
const sign = value > 0 ? "+" : value < 0 ? "−" : "±";
|
||||
const colorClass = value < 0 ? "text-green-600" : value > 0 ? "text-red-500" : "text-gray-400";
|
||||
const modeLabel = delta.mode === "add" ? " (add)" : "";
|
||||
|
||||
return (
|
||||
<span className={`text-xs font-medium ${colorClass}`}>
|
||||
{sign}{formatted}{modeLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
$threadId.tsx
|
||||
├── selectedSetupId ← useUIStore
|
||||
├── thread ← useThread(threadId) // has thread.categoryId + candidates
|
||||
├── setupData ← useSetup(selectedSetupId) // null when none selected
|
||||
├── impactDeltas ← useImpactDeltas(candidates, setupData?.items, thread.categoryId)
|
||||
│
|
||||
├── <SetupImpactSelector /> // sets selectedSetupId in uiStore
|
||||
│
|
||||
├── <CandidateListItem delta={impactDeltas.deltas[c.id]} />
|
||||
├── <CandidateCard delta={impactDeltas.deltas[c.id]} />
|
||||
└── <ComparisonTable deltas={impactDeltas.deltas} />
|
||||
```
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- **Computing deltas in each candidate component:** Delta mode (add vs replace) must be determined once from the full setup. Computing per-component means each card independently decides mode — a setup with multiple items in different categories could give inconsistent signals if the logic is subtle.
|
||||
- **Storing selectedSetupId in URL search params:** Adds routing complexity with no benefit; the selection is ephemeral and non-shareable per project scope.
|
||||
- **Calling `useSetup` inside each candidate component:** Causes N redundant React Query calls. Call once at page level, pass deltas down.
|
||||
- **Treating `priceDelta = 0` as "no data":** Zero cost delta is a valid result (exact price match). The `null` check distinguishes missing data from zero.
|
||||
|
||||
---
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Formatted weight delta strings | Custom formatter | Reuse `formatWeight(Math.abs(delta), unit)` + sign prefix | Already handles all 4 units (g/oz/lb/kg) correctly |
|
||||
| Formatted price delta strings | Custom formatter | Reuse `formatPrice(Math.abs(delta), currency)` + sign prefix | Already handles all currencies and JPY integer case |
|
||||
| Setup list fetching | Custom fetch | `useSetups()` hook | Already defined, cached by React Query |
|
||||
| Setup items fetching | Custom fetch | `useSetup(id)` hook | Already defined with enabled guard |
|
||||
| UI state management | Local useState | Zustand `selectedSetupId` | Persists across view mode switches within same session |
|
||||
|
||||
**Key insight:** All data infrastructure exists. This phase is arithmetic + UI only.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Thread categoryId vs Candidate categoryId
|
||||
|
||||
**What goes wrong:** Using `candidate.categoryId` instead of `thread.categoryId` to find the replaced setup item. Candidates inherit the thread's category (they're in the same decision thread), but a user could theoretically pick a different category per candidate. The impact preview is about "what does buying something for this research thread do to my setup," so the match must be on the **thread** category, not individual candidate category.
|
||||
|
||||
**Why it happens:** `CandidateWithCategory` has a `categoryId` field that looks natural to use.
|
||||
|
||||
**How to avoid:** In `useImpactDeltas`, accept `threadCategoryId` as a separate parameter sourced from `thread.categoryId`, not from `candidate.categoryId`.
|
||||
|
||||
**Warning signs:** Replace mode never triggers even when a setup contains an item in the expected category.
|
||||
|
||||
### Pitfall 2: Null vs Zero for Missing Data (IMPC-04)
|
||||
|
||||
**What goes wrong:** When `candidate.weightGrams` is `null`, the delta would be `null - replacedItem.weightGrams = NaN` or JavaScript coerces to `0`. Rendering "0g" is actively misleading — it implies the candidate has been weighed at zero.
|
||||
|
||||
**Why it happens:** JavaScript's `null - 200 = -200` is NaN, not zero, but string formatting might swallow this silently.
|
||||
|
||||
**How to avoid:** Explicit null guard BEFORE arithmetic: `if (c.weightGrams == null) { weightDelta = null; }`. Render `null` delta as `"-- (no weight data)"` per IMPC-04.
|
||||
|
||||
**Warning signs:** Candidates with no weight show "0g" or "−200g" delta.
|
||||
|
||||
### Pitfall 3: useSetup Enabled Guard
|
||||
|
||||
**What goes wrong:** Calling `useSetup(null)` triggers a request to `/api/setups/null` — a 404 or server error.
|
||||
|
||||
**Why it happens:** `useSetup` has `enabled: setupId != null` guard, but if the `selectedSetupId` from Zustand is not passed correctly, it might be `undefined` rather than `null`.
|
||||
|
||||
**How to avoid:** Coerce to `null` explicitly: `useSetup(selectedSetupId ?? null)`.
|
||||
|
||||
**Warning signs:** Network errors in dev tools when no setup is selected.
|
||||
|
||||
### Pitfall 4: selectedSetupId Stale Across Thread Navigation
|
||||
|
||||
**What goes wrong:** User selects "Setup A" on thread 1, navigates to thread 2, sees impact deltas for "Setup A" which may not be relevant.
|
||||
|
||||
**Why it happens:** Zustand state persists in memory across route changes.
|
||||
|
||||
**How to avoid:** Two acceptable approaches:
|
||||
1. **Accept it** — the user chose a setup globally; they can clear it. Simplest.
|
||||
2. **Reset on thread change** — call `setSelectedSetupId(null)` in a `useEffect` that fires on `threadId` change.
|
||||
|
||||
Recommended: Accept cross-thread persistence (simpler, matches how `candidateViewMode` works currently).
|
||||
|
||||
### Pitfall 5: ComparisonTable Integration
|
||||
|
||||
**What goes wrong:** ComparisonTable already has its own `weightDeltas` computation (candidate-relative deltas: lightest vs others). Adding setup deltas as a third numeric display in the same weight cell risks visual clutter and ambiguity about which delta is which.
|
||||
|
||||
**Why it happens:** Two delta systems in one cell with no visual separation.
|
||||
|
||||
**How to avoid:** Render setup impact deltas in a **separate row** in ATTRIBUTE_ROWS, or as a clearly labeled sub-row below weight. Label it "Impact" with a small setup name indicator.
|
||||
|
||||
---
|
||||
|
||||
## Code Examples
|
||||
|
||||
Verified patterns from existing codebase:
|
||||
|
||||
### Existing Delta Pattern (ComparisonTable.tsx)
|
||||
```typescript
|
||||
// Source: src/client/components/ComparisonTable.tsx
|
||||
// This shows the established useMemo pattern for delta computation
|
||||
const { bestWeightId, bestPriceId, weightDeltas, priceDeltas } =
|
||||
useMemo(() => {
|
||||
const withWeight = candidates.filter((c) => c.weightGrams != null);
|
||||
let bestWeightId: number | null = null;
|
||||
const weightDeltas: Record<number, string | null> = {};
|
||||
// ... arithmetic over nullable numbers
|
||||
return { bestWeightId, bestPriceId, weightDeltas, priceDeltas };
|
||||
}, [candidates, unit, currency]);
|
||||
```
|
||||
|
||||
### Existing useSetup Hook
|
||||
```typescript
|
||||
// Source: src/client/hooks/useSetups.ts
|
||||
export function useSetup(setupId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: ["setups", setupId],
|
||||
queryFn: () => apiGet<SetupWithItems>(`/api/setups/${setupId}`),
|
||||
enabled: setupId != null, // CRITICAL: prevents null fetch
|
||||
});
|
||||
}
|
||||
// SetupItemWithCategory includes: categoryId, weightGrams, priceCents, name
|
||||
```
|
||||
|
||||
### Existing formatWeight / formatPrice
|
||||
```typescript
|
||||
// Source: src/client/lib/formatters.ts
|
||||
export function formatWeight(grams: number | null | undefined, unit: WeightUnit = "g"): string {
|
||||
if (grams == null) return "--";
|
||||
// handles g / oz / lb / kg
|
||||
}
|
||||
export function formatPrice(cents: number | null | undefined, currency: Currency = "USD"): string {
|
||||
if (cents == null) return "--";
|
||||
// handles JPY integer case, others to 2dp
|
||||
}
|
||||
// Pass Math.abs(delta) to these, prefix sign manually
|
||||
```
|
||||
|
||||
### Existing Zustand UI State Pattern
|
||||
```typescript
|
||||
// Source: src/client/stores/uiStore.ts
|
||||
// All ephemeral UI state lives here — follow same pattern
|
||||
candidateViewMode: "list" | "grid" | "compare";
|
||||
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;
|
||||
// Add analogously:
|
||||
// selectedSetupId: number | null;
|
||||
// setSelectedSetupId: (id: number | null) => void;
|
||||
```
|
||||
|
||||
### Thread categoryId Availability
|
||||
```typescript
|
||||
// Source: src/server/services/thread.service.ts — getThreadWithCandidates
|
||||
// thread object from useThread() has .categoryId (integer) directly available
|
||||
// Note: ThreadWithCandidates interface in useThreads.ts does NOT expose categoryId
|
||||
// The raw DB thread select does, but the hook return type may need updating
|
||||
```
|
||||
|
||||
**Important finding:** The `ThreadWithCandidates` interface in `useThreads.ts` does NOT currently include `categoryId`. The server does return it (from `db.select().from(threads)`), but the TypeScript interface omits it. The planner must add `categoryId: number` to `ThreadWithCandidates` or source it from the candidates (each `CandidateWithCategory` has `categoryId`).
|
||||
|
||||
---
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| Fetch data inside components | Custom hooks with React Query | Established in project | All data fetching via hooks |
|
||||
| Local component state for UI | Zustand store | Established in project | All UI state centralized |
|
||||
|
||||
**No deprecated patterns in scope for this phase.**
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Thread categoryId exposure in ThreadWithCandidates**
|
||||
- What we know: `getThreadWithCandidates` in thread.service.ts returns the full thread row (including `categoryId`), but `ThreadWithCandidates` TypeScript interface in `useThreads.ts` does not declare `categoryId`
|
||||
- What's unclear: Does the API actually serialize `categoryId` in the response or is it filtered?
|
||||
- Recommendation: Planner should add `categoryId: number` to `ThreadWithCandidates` interface and verify the server route includes it. Alternatively, use `thread.candidates[0]?.categoryId` as a fallback since all candidates share the thread's category.
|
||||
|
||||
2. **Selector placement on narrow viewports**
|
||||
- What we know: Thread header already has breadcrumb, title/status pill, and toolbar row
|
||||
- What's unclear: Three rows in mobile header may feel cramped
|
||||
- Recommendation: Planner's discretion — can be inline with toolbar or as a third header row. Research finds no hard constraint.
|
||||
|
||||
3. **ComparisonTable delta row placement**
|
||||
- What we know: ATTRIBUTE_ROWS pattern is extensible (just add an object to the array)
|
||||
- What's unclear: Whether impact rows should live inside the weight/price rows or as separate "Impact Weight" / "Impact Price" rows
|
||||
- Recommendation: Separate labeled rows to avoid conflating candidate-relative deltas (lightest/cheapest highlighting) with setup impact deltas.
|
||||
|
||||
---
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Bun test (built-in) |
|
||||
| Config file | none — bun test discovers tests automatically |
|
||||
| Quick run command | `bun test tests/services/` |
|
||||
| Full suite command | `bun test` |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| IMPC-01 | Delta values computed and passed to candidates when setup selected | unit (hook logic) | `bun test tests/lib/impactDeltas.test.ts` | Wave 0 |
|
||||
| IMPC-02 | Replace mode triggered when setup contains item in same category as thread | unit | `bun test tests/lib/impactDeltas.test.ts` | Wave 0 |
|
||||
| IMPC-03 | Add mode used when no category match, delta equals candidate value | unit | `bun test tests/lib/impactDeltas.test.ts` | Wave 0 |
|
||||
| IMPC-04 | Null weight candidate returns null delta (not zero, not NaN) | unit | `bun test tests/lib/impactDeltas.test.ts` | Wave 0 |
|
||||
|
||||
**Note:** Delta computation is pure arithmetic logic and can be tested outside React via an extracted pure function. The recommended approach is to extract `computeImpactDeltas(candidates, setupItems, threadCategoryId)` as a pure function and test it directly — no React Testing Library needed.
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** `bun test tests/lib/`
|
||||
- **Per wave merge:** `bun test`
|
||||
- **Phase gate:** Full suite green before `/gsd:verify-work`
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `tests/lib/impactDeltas.test.ts` — covers IMPC-01 through IMPC-04 via pure function extracted from `useImpactDeltas`
|
||||
- [ ] `tests/lib/` directory — create if not exists (pure utility tests go here)
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- Direct codebase read — `src/db/schema.ts` — verified `threadCandidates.categoryId`, `items.categoryId`, `setupItems` join structure
|
||||
- Direct codebase read — `src/client/hooks/useSetups.ts` — verified `SetupItemWithCategory` type includes `categoryId`, `weightGrams`, `priceCents`
|
||||
- Direct codebase read — `src/client/hooks/useThreads.ts` — identified missing `categoryId` in `ThreadWithCandidates` interface
|
||||
- Direct codebase read — `src/client/components/ComparisonTable.tsx` — verified ATTRIBUTE_ROWS pattern and existing delta computation pattern
|
||||
- Direct codebase read — `src/client/stores/uiStore.ts` — verified `selectedSetupId` does not yet exist, pattern for adding it
|
||||
- Direct codebase read — `src/client/lib/formatters.ts` — verified `formatWeight` / `formatPrice` reusability with abs values
|
||||
- Direct codebase read — `tests/helpers/db.ts` — verified test infrastructure, no schema changes needed
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- `.planning/STATE.md` — confirms "Impact preview must distinguish add-mode vs replace-mode by category match" as locked architectural decision
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- None
|
||||
|
||||
---
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH — zero new dependencies; all libraries confirmed in package.json
|
||||
- Architecture: HIGH — derived entirely from reading existing codebase; patterns are directly reusable
|
||||
- Pitfalls: HIGH — identified from code inspection (ThreadWithCandidates missing categoryId is a concrete finding, not speculation)
|
||||
- Delta math: HIGH — straightforward arithmetic, verified types from schema
|
||||
|
||||
**Research date:** 2026-03-17
|
||||
**Valid until:** 2026-04-17 (stable codebase; no external library research)
|
||||
78
.planning/phases/13-setup-impact-preview/13-VALIDATION.md
Normal file
78
.planning/phases/13-setup-impact-preview/13-VALIDATION.md
Normal file
@@ -0,0 +1,78 @@
|
||||
---
|
||||
phase: 13
|
||||
slug: setup-impact-preview
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: 2026-03-17
|
||||
---
|
||||
|
||||
# Phase 13 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | bun test (built-in) |
|
||||
| **Config file** | bunfig.toml |
|
||||
| **Quick run command** | `bun test` |
|
||||
| **Full suite command** | `bun test` |
|
||||
| **Estimated runtime** | ~5 seconds |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run `bun test`
|
||||
- **After every plan wave:** Run `bun test`
|
||||
- **Before `/gsd:verify-work`:** Full suite must be green
|
||||
- **Max feedback latency:** 5 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
|
||||
| 13-01-01 | 01 | 1 | IMPC-01 | unit | `bun test` | ❌ W0 | ⬜ pending |
|
||||
| 13-01-02 | 01 | 1 | IMPC-02 | unit | `bun test` | ❌ W0 | ⬜ pending |
|
||||
| 13-01-03 | 01 | 1 | IMPC-03 | unit | `bun test` | ❌ W0 | ⬜ pending |
|
||||
| 13-01-04 | 01 | 1 | IMPC-04 | unit | `bun test` | ❌ W0 | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] Test stubs for IMPC-01 through IMPC-04 impact delta computation
|
||||
- [ ] Test fixtures for setup items and thread candidates with weight/price data
|
||||
|
||||
*If none: "Existing infrastructure covers all phase requirements."*
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Setup dropdown renders in thread header | IMPC-01 | Visual/UI placement | Open a thread, verify dropdown appears with all setups listed |
|
||||
| Delta labels display correctly (add vs replace) | IMPC-03 | Visual formatting | Select setup with no category match, verify "add" label |
|
||||
| Missing weight shows "-- (no weight data)" | IMPC-04 | Visual indicator | Add candidate with no weight, verify placeholder text |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 5s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,244 +1,262 @@
|
||||
# Feature Research: v1.2 Collection Power-Ups
|
||||
# Feature Research
|
||||
|
||||
**Domain:** Gear management -- search/filter, weight classification, weight visualization, candidate status tracking, weight unit selection
|
||||
**Domain:** Gear management — candidate comparison, setup impact preview, and candidate ranking
|
||||
**Researched:** 2026-03-16
|
||||
**Confidence:** HIGH
|
||||
**Scope:** New features only. v1.0/v1.1 features (item CRUD, categories, threads, setups, dashboard, onboarding, images, icons) are already shipped.
|
||||
**Confidence:** HIGH (existing codebase fully understood; UX patterns verified via multiple sources)
|
||||
|
||||
## Table Stakes
|
||||
---
|
||||
|
||||
Features that gear management users expect. Missing these makes the app feel incomplete for collections beyond ~20 items.
|
||||
## Context
|
||||
|
||||
| Feature | Why Expected | Complexity | Dependencies on Existing |
|
||||
|---------|--------------|------------|--------------------------|
|
||||
| Search items by name | Every competitor with an inventory concept has search. Hikt highlights "searchable digital closet." PackLight Supporter Edition has inventory search. Once a collection exceeds 30 items, scrolling to find something is painful. LighterPack notably lacks this and users complain. | LOW | Items query (`useItems`), collection view. Client-side only -- no API changes needed for <500 items. |
|
||||
| Filter items by category | Already partially exists in Planning view (category dropdown for threads). Collection view groups by category visually but has no filter. Users need to quickly narrow to "show me just my shelter items." | LOW | Categories query (`useCategories`), collection view. Client-side filtering of already-fetched items. |
|
||||
| Weight unit selection (g, oz, lb, kg) | Universal across all competitors. LighterPack supports toggling between g/oz/lb/kg. Packrat offers per-item input in any unit with display conversion. Backpacking Light forum users specifically praise apps that let you "enter item weights in grams and switch the entire display to lbs & oz." Gear specs come in mixed units -- a sleeping bag in lbs/oz, a fuel canister in grams. | LOW | `formatWeight()` in `lib/formatters.ts`, `settings` table (already exists with key/value store), TotalsBar, ItemCard, CandidateCard, SetupCard -- every weight display. |
|
||||
| Weight classification (base/worn/consumable) | LighterPack pioneered this three-way split and it is now universal. Hikt, PackLight, Packstack, HikeLite, 99Boulders spreadsheet -- all support it. "Base weight" is the core metric of the ultralight community. Without classification, weight totals are a single number with no actionable insight. | MEDIUM | `setup_items` join table (needs new column), setup detail view, setup service, totals computation. Schema migration required. |
|
||||
This is a subsequent milestone research file for **v1.3 Research & Decision Tools**.
|
||||
The features below are **additive** to v1.2. All three features operate within the existing
|
||||
`threads/$threadId` page and its data model.
|
||||
|
||||
## Differentiators
|
||||
**Existing data model relevant to this milestone:**
|
||||
- `threadCandidates`: id, threadId, name, weightGrams, priceCents, categoryId, notes, productUrl, imageFilename, status — no rank, pros, or cons columns yet
|
||||
- `setups` + `setupItems`: stores weight/cost per setup item with classification (base/worn/consumable)
|
||||
- `getSetupWithItems` already returns `classification` per item — available for impact preview
|
||||
|
||||
Features that set GearBox apart or add meaningful value beyond what competitors offer.
|
||||
---
|
||||
|
||||
| Feature | Value Proposition | Complexity | Dependencies on Existing |
|
||||
|---------|-------------------|------------|--------------------------|
|
||||
| Weight distribution visualization (donut/pie chart) | LighterPack's pie chart is iconic and widely cited as its best feature. "The pie chart at the top is a great way to visualize how your pack weight breaks down by category." PackLight uses bar graphs. GearBox can do this per-setup with a modern donut chart that also shows base/worn/consumable breakdown -- a combination no competitor offers cleanly. | MEDIUM | Totals data (already computed server-side per category), weight classification (new), a chart library (react-minimal-pie-chart at 2kB or Recharts). |
|
||||
| Candidate status tracking (researching/ordered/arrived) | No competitor has this. Research confirmed: the specific workflow of tracking purchase status through stages does not exist in any gear management app. This is unique to GearBox's planning thread concept. It makes threads a living document of the purchase lifecycle, not just a comparison tool. | LOW | `thread_candidates` table (needs new `status` column), CandidateCard, CandidateForm. Simple text field migration. |
|
||||
| Planning category filter with icon-aware dropdown | Already partially built as a plain `<select>` in PlanningView. Upgrading to show Lucide icons alongside category names makes filtering feel polished and consistent with the icon picker UX. | LOW | Existing CategoryPicker component pattern, existing category filter state in PlanningView. |
|
||||
| Weight classification shown per-setup (not global) | In LighterPack, worn/consumable flags are per-item within a list. In GearBox, items exist in a global collection and appear in multiple setups. The same jacket might be "worn" in a summer bikepacking setup but "base weight" (packed in panniers) in a winter setup. Classification belongs on the setup_items join, not on the item itself. This is architecturally superior to competitors. | MEDIUM | `setup_items` table schema, setup sync endpoint, setup detail UI. |
|
||||
## Feature Landscape
|
||||
|
||||
## Anti-Features
|
||||
### Table Stakes (Users Expect These)
|
||||
|
||||
Features to explicitly NOT build in this milestone.
|
||||
Features users assume exist in any comparison or decision tool. Missing these makes the thread
|
||||
detail page feel incomplete as a decision workspace.
|
||||
|
||||
| Anti-Feature | Why Avoid | What to Do Instead |
|
||||
|--------------|-----------|-------------------|
|
||||
| Per-item weight input in multiple units | Packrat lets you enter "2 lb 3 oz" per item. This adds parsing complexity, ambiguous storage, and conversion bugs. | Store grams internally (already done). Convert for display only. Users enter grams; if they want oz input, they convert mentally or we add a unit toggle on the input field later. |
|
||||
| Interactive chart drill-down (click to zoom) | LighterPack lets you click pie slices to zoom into category breakdowns. Adds significant interaction complexity. | Static donut chart with hover tooltips. Drill-down is a future enhancement. |
|
||||
| Weight goals / targets ("your target base weight is X") | Some apps show ultralight thresholds. Adds opinionated norms that conflict with hobby-agnostic design. | Show the numbers. Let users interpret them. |
|
||||
| Custom weight classification labels | Beyond base/worn/consumable. Some users want "luxury" or "shared" categories. | Three classifications cover 95% of use cases. The notes field handles edge cases. |
|
||||
| Server-side search / full-text search | SQLite FTS5 or similar. Premature for a single-user app with <1000 items. | Client-side filtering of the already-fetched items array. Simpler, faster for the expected data scale. |
|
||||
| Worn/consumable at the global item level | Tempting to add a classification column to the `items` table. | Classification varies by setup context. A rain shell is "worn" on a day hike but "base weight" (packed) on a bike tour. The join table `setup_items` is the correct location. |
|
||||
| Feature | Why Expected | Complexity | Notes |
|
||||
|---------|--------------|------------|-------|
|
||||
| Side-by-side comparison view | Any comparison tool in any domain shows attributes aligned per-column. Card grid (current) forces mental juggling between candidates. E-commerce, spec sheets, gear apps — all use tabular layout for comparison. | MEDIUM | Rows = attributes (image, name, weight, price, status, notes, link), columns = candidates. Sticky attribute-label column during horizontal scroll. Max 3–4 candidates usable on desktop; 2 on mobile. Toggle between grid view (current) and table view. |
|
||||
| Weight delta per candidate | Gear apps (LighterPack, GearGrams) display weight totals prominently. Users replacing an item need the delta, not just the raw weight of the candidate. | LOW | Pure client-side computation: `candidate.weightGrams - existingItemWeight`. No API call needed if setup data already loaded via `useSetup`. |
|
||||
| Cost delta per candidate | Same reasoning as weight delta. A purchase decision is always the weight vs. cost tradeoff. | LOW | Same pattern as weight delta. Color-coded: green for savings/lighter, red for more expensive/heavier. |
|
||||
| Setup selector for impact preview | User needs to pick which setup to compute deltas against — not all setups contain the same category of item being replaced. | MEDIUM | Dropdown of setup names populated from `useSetups()`. When selected, loads setup via `useSetup(id)`. "No setup selected" state shows raw candidate values only, no delta. |
|
||||
|
||||
## Feature Details
|
||||
### Differentiators (Competitive Advantage)
|
||||
|
||||
### 1. Search and Filter
|
||||
Features not found in LighterPack, GearGrams, or any other gear app. Directly serve the
|
||||
"decide between candidates" workflow that is unique to GearBox.
|
||||
|
||||
**What users expect:** A text input that filters visible items by name as you type. A category dropdown or pill selector to filter by category. Both should work together (search within a category).
|
||||
| Feature | Value Proposition | Complexity | Notes |
|
||||
|---------|-------------------|------------|-------|
|
||||
| Drag-to-rank ordering | Makes priority explicit without a numeric input. Ranking communicates "this is my current top pick." Maps to how users mentally stack-rank options during research. No competitor has this in the gear domain. | MEDIUM | `@dnd-kit/sortable` is the current standard (actively maintained; `react-beautiful-dnd` is abandoned as of 2025). Requires new `rank` integer column on `threadCandidates`. Persist order via PATCH endpoint. |
|
||||
| Per-candidate pros/cons fields | Freeform text capturing the reasoning behind ranking. LighterPack and GearGrams have notes per item but no structured decision rationale. Differentiates GearBox as a decision tool, not just a list tracker. | LOW | Two textarea fields per candidate. New `pros` and `cons` text columns on `threadCandidates`. Visible in comparison view rows and candidate edit panel. |
|
||||
| Impact preview with category-matched delta | Setup items have a category. The most meaningful delta is weight saved within the same category (e.g., comparing sleeping pads, subtract current sleeping pad weight from setup total). More actionable than comparing against the entire setup total. | MEDIUM | Use `candidate.categoryId` to find matching setup items and compute delta. Edge case: no item of that category in the setup → show "not in setup." Data already available from `getSetupWithItems`. |
|
||||
|
||||
**Domain patterns observed:**
|
||||
- Hikt: Searchable gear closet with category and specification filters
|
||||
- PackLight: Inventory search (premium feature) with category organization
|
||||
- Backpacking Light Calculator: Search filter in gear locker and within packs
|
||||
- LighterPack: No text search -- widely considered a gap
|
||||
### Anti-Features (Commonly Requested, Often Problematic)
|
||||
|
||||
**Recommended implementation:**
|
||||
- Sticky search bar above the collection grid with a text input and category filter dropdown
|
||||
- Client-side filtering using `Array.filter()` on the items array from `useItems()`
|
||||
- Case-insensitive substring match on item name
|
||||
- Category filter as pills or dropdown (reuse the pattern from PlanningView)
|
||||
- URL search params for filter state (shareable filtered views, consistent with existing `?tab=` pattern)
|
||||
- Clear filters button when any filter is active
|
||||
- Result count displayed ("showing 12 of 47 items")
|
||||
| Feature | Why Requested | Why Problematic | Alternative |
|
||||
|---------|---------------|-----------------|-------------|
|
||||
| Custom comparison attributes | "I want to compare battery life, durability, color..." | PROJECT.md explicitly rejects this as a complexity trap. Custom attributes require schema generalization, dynamic rendering, and data entry friction for every candidate. | Notes field and pros/cons fields cover the remaining use cases. |
|
||||
| Score/rating calculation | Automatically rank candidates by computed score | Score algorithms require encoding the user's weight-vs-price preference — personalization complexity. Users distrust opaque scores. | Manual drag-to-rank expresses the user's own weighting without encoding it in an algorithm. |
|
||||
| Side-by-side comparison across threads | Compare candidates from different research threads | Candidates belong to different purchase decisions — mixing them is conceptually incoherent. Different categories are never apples-to-apples. | Thread remains the scope boundary. Cross-thread planning is what setups are for. |
|
||||
| Comparison permalink/share | Share a comparison view URL | GearBox is single-user, no auth for v1. Sharing requires auth, user management, public/private visibility. | Out of scope for v1 per PROJECT.md. Future feature. |
|
||||
| Classification-aware impact preview as MVP requirement | Show delta broken down by base/worn/consumable | While data is available, the classification breakdown adds significant UI complexity. The flat delta answers "will this make my setup lighter?" which is 90% of the use case. | Flat delta for MVP. Classification-aware breakdown as a follow-up enhancement (P2). |
|
||||
|
||||
**Complexity:** LOW. Pure client-side. No API changes. ~100 lines of new component code plus minor state management.
|
||||
|
||||
### 2. Weight Classification (Base/Worn/Consumable)
|
||||
|
||||
**What users expect:** Every item in a setup can be marked as one of three types:
|
||||
- **Base weight**: Items carried in the pack. The fixed weight of your loadout. This is the primary metric ultralight hikers optimize.
|
||||
- **Worn weight**: Items on your body while hiking (shoes, primary clothing, watch, sunglasses). Not counted toward pack weight but tracked as part of "skin-out" weight.
|
||||
- **Consumable weight**: Items that deplete during a trip (food, water, fuel, sunscreen). Variable weight not counted toward base weight.
|
||||
|
||||
**Domain patterns observed:**
|
||||
- LighterPack: Per-item icons (shirt icon = worn, flame icon = consumable). Default = base weight. Totals show base/worn/consumable/total separately.
|
||||
- Packstack: "Separates base weight, worn weight, and consumables so you always know exactly what your pack weighs."
|
||||
- HikeLite: "Mark heavy clothing as worn to see your true base weight."
|
||||
- 99Boulders spreadsheet: Column with dropdown: WORN / CONSUMABLE / - (dash = base).
|
||||
|
||||
**Critical design decision -- classification scope:**
|
||||
In LighterPack, items only exist within lists, so the flag is per-item-per-list inherently. In GearBox, items live in a global collection and are referenced by setups. The classification MUST live on the `setup_items` join table, not on the `items` table. Reason: the same item can have different classifications in different setups (a puffy jacket is "worn" on a cold-weather hike but "base weight" in a three-season setup where it stays packed).
|
||||
|
||||
**Recommended implementation:**
|
||||
- Add `classification TEXT NOT NULL DEFAULT 'base'` column to `setup_items` table
|
||||
- Valid values: `"base"`, `"worn"`, `"consumable"`
|
||||
- Default to `"base"` (most items are base weight; this matches user expectation)
|
||||
- UI: Small segmented control or icon toggle on each item within the setup detail view
|
||||
- LighterPack-style icons: backpack icon (base), shirt icon (worn), flame/droplet icon (consumable)
|
||||
- Setup totals recalculated: show base weight, worn weight, consumable weight, and total (skin-out) as four separate numbers
|
||||
- SQL aggregation update: `SUM(CASE WHEN classification = 'base' THEN weight_grams ELSE 0 END)` etc.
|
||||
|
||||
**Complexity:** MEDIUM. Requires schema migration, API changes (sync endpoint must accept classification), service layer updates, and UI for per-item classification within setup views.
|
||||
|
||||
### 3. Weight Distribution Visualization
|
||||
|
||||
**What users expect:** A chart showing where the weight is. By category is standard. By classification (base/worn/consumable) is a bonus.
|
||||
|
||||
**Domain patterns observed:**
|
||||
- LighterPack: Color-coded pie chart by category, click to drill down. "As you enter each piece of equipment, a pie chart immediately displays a breakdown of where your weight is appropriated." Colors are customizable per category.
|
||||
- PackLight: Bar graph comparing category weights
|
||||
- OutPack: Category breakdown graph
|
||||
|
||||
**Two chart contexts:**
|
||||
1. **Collection-level**: Weight by category across the whole collection. Uses existing `useTotals()` data.
|
||||
2. **Setup-level**: Weight by category AND by classification within a specific setup. More useful because setups represent actual loadouts.
|
||||
|
||||
**Recommended implementation:**
|
||||
- Donut chart (modern feel, consistent with GearBox's minimalist aesthetic)
|
||||
- Library: `react-minimal-pie-chart` (2kB gzipped, zero dependencies, SVG-based) over Recharts (40kB+). GearBox only needs pie/donut -- no line charts, bar charts, etc.
|
||||
- Setup detail view: Donut chart showing weight by category, with center text showing total base weight
|
||||
- Optional toggle: switch between "by category" and "by classification" views
|
||||
- Color assignment: Derive from category or classification type (base = neutral gray, worn = blue, consumable = amber)
|
||||
- Hover tooltips showing category name, weight, and percentage
|
||||
- Responsive: Chart should work on mobile viewports
|
||||
|
||||
**Complexity:** MEDIUM. New dependency, new component, integration with totals data. The chart itself is straightforward; the data aggregation for per-setup-per-category-per-classification is the main work.
|
||||
|
||||
### 4. Candidate Status Tracking
|
||||
|
||||
**What users expect:** This is novel -- no competitor has it. The workflow mirrors real purchase behavior:
|
||||
1. **Researching** (default): You found this product, added it to a thread, and are evaluating it
|
||||
2. **Ordered**: You decided to buy it and placed an order
|
||||
3. **Arrived**: The product has been delivered. Ready for thread resolution.
|
||||
|
||||
**Why this matters:** Without status tracking, threads are a flat list of candidates. With it, threads become a living purchase tracker. A user can see at a glance "I ordered the Nemo Tensor, still researching two other pads."
|
||||
|
||||
**Recommended implementation:**
|
||||
- Add `status TEXT NOT NULL DEFAULT 'researching'` column to `thread_candidates` table
|
||||
- Valid values: `"researching"`, `"ordered"`, `"arrived"`
|
||||
- UI: Status badge on CandidateCard (small colored pill, similar to existing weight/price badges)
|
||||
- Color scheme: researching = gray/neutral, ordered = amber/yellow, arrived = green
|
||||
- Status change: Dropdown or simple click-to-cycle on the candidate card
|
||||
- Thread-level summary: Show count by status ("2 researching, 1 ordered")
|
||||
- When resolving a thread, only candidates with status "arrived" should be selectable as winners (soft constraint -- show a warning, not a hard block, since users may resolve with a "researching" candidate they just bought in-store)
|
||||
|
||||
**Complexity:** LOW. Simple column addition, enum-like text field, badge rendering, optional status transition UI.
|
||||
|
||||
### 5. Weight Unit Selection
|
||||
|
||||
**What users expect:** Choose a preferred unit (grams, ounces, pounds, kilograms) and have ALL weight displays in the app use that unit. LighterPack toggles between g/oz/lb/kg at the top level. The BPL Calculator app lets you "enter item weights in grams and switch the entire display to lbs & oz."
|
||||
|
||||
**Domain patterns observed:**
|
||||
- LighterPack: Toggle at list level between lb/oz/g/kg. Only changes summary display, not per-item display.
|
||||
- Packrat: "Input items in different units, choose how they're displayed, and freely convert between them."
|
||||
- BPL Calculator: Global settings change, applied to all displays
|
||||
- WeighMyGear: Input locked to grams, less intuitive
|
||||
|
||||
**Recommended implementation:**
|
||||
- Store preference in existing `settings` table as `{ key: "weightUnit", value: "g" }` (default: grams)
|
||||
- Supported units: `g` (grams), `oz` (ounces), `lb` (pounds + ounces), `kg` (kilograms)
|
||||
- Conversion constants: 1 oz = 28.3495g, 1 lb = 453.592g, 1 kg = 1000g
|
||||
- Display format per unit:
|
||||
- `g`: "450g" (round to integer)
|
||||
- `oz`: "15.9 oz" (one decimal)
|
||||
- `lb`: "2 lb 3 oz" (pounds + remainder ounces, traditional format)
|
||||
- `kg`: "1.45 kg" (two decimals)
|
||||
- Update `formatWeight()` to accept unit parameter or read from a React context/hook
|
||||
- Settings UI: Simple dropdown or segmented control, accessible from a settings page or inline in the TotalsBar
|
||||
- Internal storage stays as grams (already the case with `weight_grams` column)
|
||||
- Affects: TotalsBar, ItemCard, CandidateCard, SetupCard, CategoryHeader, setup detail view, chart tooltips
|
||||
|
||||
**Complexity:** LOW. No schema changes. Update the `formatWeight()` function, add a settings hook, propagate the unit to all display points. The main effort is touching every component that displays weight (there are ~6-8 call sites).
|
||||
|
||||
### 6. Planning Category Filter with Icon-Aware Dropdown
|
||||
|
||||
**What users expect:** The existing category filter in PlanningView is a plain `<select>` without icons. Since categories now have Lucide icons (v1.1), the filter should show them.
|
||||
|
||||
**Recommended implementation:**
|
||||
- Replace the native `<select>` with a custom dropdown component that renders `<LucideIcon>` alongside category names
|
||||
- Match the visual style of the CategoryPicker used in thread creation
|
||||
- Same functionality, better visual consistency
|
||||
|
||||
**Complexity:** LOW. UI-only change. Replace ~20 lines of `<select>` with a custom dropdown component.
|
||||
---
|
||||
|
||||
## Feature Dependencies
|
||||
|
||||
```
|
||||
[Weight Unit Selection] --independent-- (affects all displays, no schema changes)
|
||||
|
|
||||
+-- should ship first (all other features benefit from correct unit display)
|
||||
[Side-by-side comparison view]
|
||||
└──requires──> [All candidate fields visible in UI]
|
||||
(weightGrams, priceCents, notes, productUrl already in schema)
|
||||
└──enhances──> [Pros/cons fields] (displayed as comparison rows)
|
||||
└──enhances──> [Drag-to-rank] (rank number shown as position in comparison columns)
|
||||
└──enhances──> [Impact preview] (delta displayed per-column inline)
|
||||
|
||||
[Search & Filter] --independent-- (pure client-side, no schema changes)
|
||||
|
|
||||
+-- no dependencies on other v1.2 features
|
||||
[Impact preview (weight + cost delta)]
|
||||
└──requires──> [Setup selector] (user picks which setup to compute delta against)
|
||||
└──requires──> [Setup data client-side] (useSetup hook already exists, no new API)
|
||||
└──requires──> [Candidate weight/price data] (already in threadCandidates schema)
|
||||
|
||||
[Candidate Status Tracking] --independent-- (schema change on thread_candidates only)
|
||||
|
|
||||
+-- no dependencies on other v1.2 features
|
||||
[Setup selector]
|
||||
└──requires──> [useSetups() hook] (already exists in src/client/hooks/useSetups.ts)
|
||||
└──requires──> [useSetup(id) hook] (already exists, loads items with classification)
|
||||
|
||||
[Weight Classification] --depends-on--> [existing setup_items table]
|
||||
|
|
||||
+-- schema migration on setup_items
|
||||
+-- enables [Weight Distribution Visualization]
|
||||
[Drag-to-rank]
|
||||
└──requires──> [rank INTEGER column on threadCandidates] (new — schema migration)
|
||||
└──requires──> [PATCH /api/threads/:id/candidates/rank endpoint] (new API endpoint)
|
||||
└──enhances──> [Side-by-side comparison] (rank visible as position indicator)
|
||||
└──enhances──> [Card grid view] (rank badge on each CandidateCard)
|
||||
|
||||
[Weight Distribution Visualization] --depends-on--> [Weight Classification]
|
||||
|
|
||||
+-- needs classification data to show base/worn/consumable breakdown
|
||||
+-- can show by-category chart without classification (partial value)
|
||||
+-- new dependency: react-minimal-pie-chart
|
||||
|
||||
[Planning Category Filter Icons] --depends-on--> [existing CategoryPicker pattern]
|
||||
|
|
||||
+-- pure UI enhancement
|
||||
[Pros/cons fields]
|
||||
└──requires──> [pros TEXT column on threadCandidates] (new — schema migration)
|
||||
└──requires──> [cons TEXT column on threadCandidates] (new — schema migration)
|
||||
└──requires──> [updateCandidateSchema extended] (add pros/cons to Zod schema)
|
||||
└──enhances──> [CandidateForm edit panel] (new textarea fields)
|
||||
└──enhances──> [Side-by-side comparison] (pros/cons rows in comparison table)
|
||||
```
|
||||
|
||||
### Implementation Order Rationale
|
||||
### Dependency Notes
|
||||
|
||||
1. **Weight Unit Selection** first -- touches formatting everywhere, foundational for all subsequent weight displays
|
||||
2. **Search & Filter** second -- standalone, immediately useful, low risk
|
||||
3. **Candidate Status Tracking** third -- standalone schema change, simple
|
||||
4. **Planning Category Filter** fourth -- quick UI polish
|
||||
5. **Weight Classification** fifth -- most complex schema change, affects setup data model
|
||||
6. **Weight Distribution Visualization** last -- depends on classification, needs chart library, highest UI complexity
|
||||
- **Side-by-side comparison is independent of schema changes.** It can be built using
|
||||
existing candidate data. No migrations required. Delivers value immediately.
|
||||
- **Impact preview is independent of schema changes.** Uses existing `useSetups` and
|
||||
`useSetup` hooks client-side. Delta computation is pure math in the component.
|
||||
No new API endpoint needed for MVP.
|
||||
- **Drag-to-rank requires schema migration.** `rank` column must be added to
|
||||
`threadCandidates`. Default ordering on migration = `createdAt` ascending.
|
||||
- **Pros/cons requires schema migration.** Two nullable `text` columns on
|
||||
`threadCandidates`. Low risk — nullable, backwards compatible.
|
||||
- **Comparison view enhances everything.** Best delivered after rank and pros/cons
|
||||
schema work is done so the full table is useful from day one.
|
||||
|
||||
## Complexity Summary
|
||||
---
|
||||
|
||||
| Feature | Schema Change | API Change | New Dependency | UI Scope | Overall |
|
||||
|---------|---------------|------------|----------------|----------|---------|
|
||||
| Search & Filter | None | None | None | Collection view only | LOW |
|
||||
| Weight Unit Selection | None (uses settings) | None (settings API exists) | None | All weight displays (~8 components) | LOW |
|
||||
| Candidate Status Tracking | `thread_candidates.status` column | Update candidate CRUD | None | CandidateCard, CandidateForm | LOW |
|
||||
| Planning Category Filter | None | None | None | PlanningView dropdown | LOW |
|
||||
| Weight Classification | `setup_items.classification` column | Update setup sync + detail endpoints | None | Setup detail view | MEDIUM |
|
||||
| Weight Distribution Chart | None | Possibly new totals endpoint | react-minimal-pie-chart (~2kB) | New chart component | MEDIUM |
|
||||
## MVP Definition
|
||||
|
||||
### Launch With (v1.3 milestone)
|
||||
|
||||
- [ ] **Side-by-side comparison view** — Core deliverable. Replace mental juggling of the card
|
||||
grid with a scannable table. No schema changes. Highest ROI, lowest risk.
|
||||
- [ ] **Impact preview: flat weight + cost delta per candidate** — Shows `+/- X g` and
|
||||
`+/- $Y` vs. the selected setup. Pure client-side math. No schema changes.
|
||||
- [ ] **Setup selector** — Dropdown of user's setups. Required for impact preview. One
|
||||
interaction: pick a setup, see deltas update.
|
||||
- [ ] **Drag-to-rank** — Requires `rank` column migration. `@dnd-kit/sortable` handles
|
||||
the drag UX. Persist via new PATCH endpoint.
|
||||
- [ ] **Pros/cons text fields** — Requires `pros` + `cons` column migration. Trivially low
|
||||
implementation complexity once schema is in place.
|
||||
|
||||
### Add After Validation (v1.x)
|
||||
|
||||
- [ ] **Classification-aware impact preview** — Delta broken down by base/worn/consumable.
|
||||
Higher complexity UI. Add once flat delta is validated as useful.
|
||||
Trigger: user feedback requests "which classification does this affect?"
|
||||
- [ ] **Rank indicator on card grid** — Small "1st", "2nd" badge on CandidateCard.
|
||||
Trigger: users express confusion about which candidate is ranked first without entering
|
||||
comparison view.
|
||||
- [ ] **Comparison view on mobile** — Horizontal scroll works but is not ideal. Consider
|
||||
attribute-focus swipe view. Trigger: usage data shows mobile traffic on thread pages.
|
||||
|
||||
### Future Consideration (v2+)
|
||||
|
||||
- [ ] **Comparison permalink** — Requires auth/multi-user work first.
|
||||
- [ ] **Auto-fill from product URL** — Fragile scraping, rejected in PROJECT.md.
|
||||
- [ ] **Custom comparison attributes** — Explicitly rejected in PROJECT.md.
|
||||
|
||||
---
|
||||
|
||||
## Feature Prioritization Matrix
|
||||
|
||||
| Feature | User Value | Implementation Cost | Priority |
|
||||
|---------|------------|---------------------|----------|
|
||||
| Side-by-side comparison view | HIGH | MEDIUM | P1 |
|
||||
| Setup impact preview (flat delta) | HIGH | LOW | P1 |
|
||||
| Setup selector for impact preview | HIGH | LOW | P1 |
|
||||
| Drag-to-rank ordering | MEDIUM | MEDIUM | P1 |
|
||||
| Pros/cons text fields | MEDIUM | LOW | P1 |
|
||||
| Classification-aware impact preview | MEDIUM | HIGH | P2 |
|
||||
| Rank indicator on card grid | LOW | LOW | P2 |
|
||||
| Mobile-optimized comparison view | LOW | MEDIUM | P3 |
|
||||
|
||||
**Priority key:**
|
||||
- P1: Must have for this milestone launch
|
||||
- P2: Should have, add when possible
|
||||
- P3: Nice to have, future consideration
|
||||
|
||||
---
|
||||
|
||||
## Competitor Feature Analysis
|
||||
|
||||
| Feature | LighterPack | GearGrams | OutPack | Our Approach |
|
||||
|---------|-------------|-----------|---------|--------------|
|
||||
| Side-by-side candidate comparison | None (list only) | None (library + trip list) | None | Inline comparison table on thread detail page, toggle from grid view |
|
||||
| Impact preview / weight delta | None (duplicate lists manually to compare) | None (no delta concept) | None | Per-candidate delta vs. selected setup, computed client-side |
|
||||
| Candidate ranking | None | None | None | Drag-to-rank with persisted `rank` column |
|
||||
| Pros/cons annotation | None (notes field only) | None (notes field only) | None | Dedicated `pros` and `cons` fields separate from general notes |
|
||||
| Status tracking | None | "wish list" item flag only | None | Already built in v1.2 (researching/ordered/arrived) |
|
||||
|
||||
**Key insight:** No existing gear management tool has a comparison view, delta preview, or
|
||||
ranking system for candidates within a research thread. This is an unmet-need gap.
|
||||
The features are adapted from general product comparison UX (e-commerce) to the gear domain.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes by Feature
|
||||
|
||||
### Side-by-side Comparison View
|
||||
|
||||
- Rendered as a transposed table: rows = attribute labels, columns = candidates.
|
||||
- Rows: Image (thumbnail), Name, Weight, Price, Status, Notes, Link, Pros, Cons, Rank, Impact Delta (weight), Impact Delta (cost).
|
||||
- Sticky first column (attribute label) while candidate columns scroll horizontally for 3+.
|
||||
- Candidate images at reduced aspect ratio (square thumbnail ~80px).
|
||||
- Weight/price cells use existing `formatWeight` / `formatPrice` formatters with the user's preferred unit.
|
||||
- Status cell reuses existing `StatusBadge` component.
|
||||
- "Pick as winner" action available per column (reuses existing `openResolveDialog`).
|
||||
- Toggle between grid view (current) and table view. Preserve both modes. Default to grid;
|
||||
user activates comparison mode explicitly.
|
||||
- Comparison mode is a UI state only (Zustand or local component state) — no URL change needed.
|
||||
|
||||
### Impact Preview
|
||||
|
||||
- Setup selector: `<select>` or custom dropdown populated from `useSetups()`.
|
||||
- On selection: load setup via `useSetup(id)`. Compute delta per candidate:
|
||||
`candidate.weightGrams - matchingCategoryWeight` where `matchingCategoryWeight` is the
|
||||
sum of setup item weights in the same category as the thread.
|
||||
- Delta display: colored pill on each candidate column in comparison view:
|
||||
- Negative delta (lighter) = green, prefixed with "−"
|
||||
- Positive delta (heavier) = red, prefixed with "+"
|
||||
- Zero = neutral gray
|
||||
- Same pattern for cost delta.
|
||||
- "No setup selected" state = no delta row shown.
|
||||
- "Category not in setup" state = "not in setup" label instead of delta.
|
||||
- No new API endpoints required. All data is client-side once setups are loaded.
|
||||
|
||||
### Drag-to-Rank
|
||||
|
||||
- `@dnd-kit/sortable` with `SortableContext` wrapping the candidate list.
|
||||
- `useSortable` hook per candidate with a drag handle (Lucide `grip-vertical` icon).
|
||||
- Drag handle visible always (not hover-only) so the affordance is clear.
|
||||
- On `onDragEnd`: recompute ranks using `arrayMove()`, call
|
||||
`PATCH /api/threads/:threadId/candidates/rank` with `{ orderedIds: number[] }`.
|
||||
- Server endpoint: bulk update `rank` for each candidate ID in the thread atomically.
|
||||
- `rank` column: `INTEGER` nullable. Null = unranked (treated as lowest rank). Default to
|
||||
`createdAt` order on first explicit rank save.
|
||||
- Rank number badge: displayed on each CandidateCard corner (small gray circle, "1", "2", "3").
|
||||
- Works in both grid view and comparison view.
|
||||
|
||||
### Pros/Cons Fields
|
||||
|
||||
- Two `textarea` inputs added to the existing `CandidateForm` (slide-out panel).
|
||||
- Labels: "Pros" and "Cons" — plain text, no icons.
|
||||
- Displayed below the existing Notes field in the form.
|
||||
- Extend `updateCandidateSchema` with `pros: z.string().optional()` and `cons: z.string().optional()`.
|
||||
- In comparison table: pros and cons rows display as plain text, line-wrapped.
|
||||
- In card grid: pros/cons not shown on card surface (too much density). Visible only in edit
|
||||
panel and comparison view.
|
||||
|
||||
### Schema Changes Required
|
||||
|
||||
Two schema migrations needed for this milestone:
|
||||
|
||||
```sql
|
||||
-- Migration 1: Candidate rank
|
||||
ALTER TABLE thread_candidates ADD COLUMN rank INTEGER;
|
||||
|
||||
-- Migration 2: Candidate pros/cons
|
||||
ALTER TABLE thread_candidates ADD COLUMN pros TEXT;
|
||||
ALTER TABLE thread_candidates ADD COLUMN cons TEXT;
|
||||
```
|
||||
|
||||
Both columns are nullable and backwards compatible. Existing candidates get `NULL` values.
|
||||
UI treats `NULL` rank as unranked, `NULL` pros/cons as empty string.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- [LighterPack](https://lighterpack.com/) -- weight classification and pie chart visualization patterns
|
||||
- [LighterPack Tutorial (99Boulders)](https://www.99boulders.com/lighterpack-tutorial) -- detailed feature walkthrough
|
||||
- [LighterPack Tutorial (Backpackers.com)](https://backpackers.com/blog/how-to-calculate-backpack-weight-with-lighterpack/) -- base/worn/consumable definitions
|
||||
- [Hikt](https://hikt.app/) -- searchable gear closet, base vs worn weight display
|
||||
- [PackLight (iOS)](https://apps.apple.com/us/app/packlight-for-backpacking/id1054845207) -- search, custom categories, bar graph visualization
|
||||
- [Packstack](https://www.packstack.io/) -- base/worn/consumable weight separation
|
||||
- [HikeLite](https://hikeliteapp.com/) -- worn weight marking, CSV import format
|
||||
- [Packrat](https://www.packrat.app/) -- flexible weight unit input and display conversion
|
||||
- [BPL Calculator Forum Discussion](https://backpackinglight.com/forums/topic/new-backpacking-hiking-weight-calculator-app/) -- unit conversion UX, search filter patterns
|
||||
- [react-minimal-pie-chart (GitHub)](https://github.com/toomuchdesign/react-minimal-pie-chart) -- 2kB lightweight chart library
|
||||
- [Best React Chart Libraries 2025 (LogRocket)](https://blog.logrocket.com/best-react-chart-libraries-2025/) -- chart library comparison
|
||||
- [LighterPack GitHub Issues](https://github.com/galenmaly/lighterpack/issues) -- user feature requests
|
||||
- [OutPack](https://outpack.app/) -- modern LighterPack alternative with category breakdown graphs
|
||||
- [Pack Weight Calculator Guide (BackpackPeek)](https://backpackpeek.com/blog/pack-weight-calculator-base-weight-guide) -- base weight calculation methodology
|
||||
- [Designing The Perfect Feature Comparison Table — Smashing Magazine](https://www.smashingmagazine.com/2017/08/designing-perfect-feature-comparison-table/) — table layout patterns, sticky headers, progressive disclosure (HIGH confidence)
|
||||
- [Comparison Tables for Products, Services, and Features — Nielsen Norman Group](https://www.nngroup.com/articles/comparison-tables/) — information architecture for comparison, anti-patterns (HIGH confidence)
|
||||
- [The Ultimate Drag-and-Drop Toolkit for React: @dnd-kit — BrightCoding (2025)](https://www.blog.brightcoding.dev/2025/08/21/the-ultimate-drag-and-drop-toolkit-for-react-a-deep-dive-into-dnd-kit/) — confirmed dnd-kit as current standard, react-beautiful-dnd abandoned (HIGH confidence)
|
||||
- [dnd-kit Sortable Docs](https://docs.dndkit.com/presets/sortable) — SortableContext, useSortable, arrayMove patterns (HIGH confidence)
|
||||
- [Ultralight: The Gear Tracking App I'm Leaving LighterPack For — TrailsMag](https://trailsmag.net/blogs/hiker-box/ultralight-the-gear-tracking-app-i-m-leaving-lighterpack-for) — LighterPack feature gap analysis (MEDIUM confidence)
|
||||
- [Comparing products: UX design best practices — Contentsquare](https://contentsquare.com/blog/comparing-products-design-practices-to-help-your-users-avoid-fragmented-comparison-7/) — fragmented comparison UX pitfalls (HIGH confidence)
|
||||
- [Drag and drop UI examples and UX tips — Eleken](https://www.eleken.co/blog-posts/drag-and-drop-ui) — drag affordance and visual feedback patterns (MEDIUM confidence)
|
||||
- GearBox codebase analysis (src/db/schema.ts, src/server/services/, src/client/hooks/) — confirmed existing data model, no rank/pros/cons columns present (HIGH confidence)
|
||||
|
||||
---
|
||||
*Feature research for: v1.2 Collection Power-Ups (search/filter, weight classification, visualization, candidate status, weight units)*
|
||||
*Feature research for: GearBox v1.3 — candidate comparison, setup impact preview, candidate ranking*
|
||||
*Researched: 2026-03-16*
|
||||
|
||||
@@ -1,202 +1,230 @@
|
||||
# Pitfalls Research
|
||||
|
||||
**Domain:** Adding search/filter, weight classification, weight distribution charts, candidate status tracking, and weight unit selection to an existing gear management app (GearBox v1.2)
|
||||
**Domain:** Adding side-by-side candidate comparison, setup impact preview, and drag-to-reorder ranking to an existing gear management app (GearBox v1.3)
|
||||
**Researched:** 2026-03-16
|
||||
**Confidence:** HIGH (pitfalls derived from direct codebase analysis + domain-specific patterns from gear tracking community + React/SQLite ecosystem knowledge)
|
||||
**Confidence:** HIGH (derived from direct codebase analysis of v1.2 + verified with dnd-kit GitHub issues, TanStack Query docs, and Baymard comparison UX research)
|
||||
|
||||
---
|
||||
|
||||
## Critical Pitfalls
|
||||
|
||||
### Pitfall 1: Weight Unit Conversion Rounding Accumulation
|
||||
### Pitfall 1: dnd-kit + React Query Cache Produces Visible Flicker on Drop
|
||||
|
||||
**What goes wrong:**
|
||||
GearBox stores weight as `real("weight_grams")` (a floating-point column in SQLite). When adding unit selection (g, oz, lb, kg), the naive approach is to convert on display and let users input in their preferred unit, converting back to grams on save. The problem: repeated round-trip conversions accumulate rounding errors. A user enters `5.3 oz`, which converts to `150.253...g`, gets stored as `150.253`, then displayed back as `5.30 oz` (fine so far). But if the user opens the edit form (which shows `5.30 oz`), makes no changes, and saves, the value reconverts from the displayed `5.30` to `150.2535g` -- a different value from what was stored. Over multiple edit cycles, weights drift. More critically, the existing `SUM(items.weight_grams)` aggregates in `setup.service.ts` and `totals.service.ts` will accumulate these micro-errors across dozens of items, producing totals that visibly disagree with manual addition of displayed values. A setup showing items of "5.3 oz + 2.1 oz" but a total of "7.41 oz" (instead of 7.40 oz) erodes trust in the app's core value proposition.
|
||||
When ranking candidates via drag-to-reorder, the naive approach is to call `mutate()` in `onDragEnd` and apply an optimistic update with `setQueryData` in the `onMutate` callback. Despite this, the item visibly snaps back to its original position for a split second before settling in the new position. The user sees a "jump" on every successful drop, which makes the ranking feature feel broken even though the data is correct.
|
||||
|
||||
**Why it happens:**
|
||||
The conversion factor between grams and ounces (28.3495) is irrational enough that floating-point representation always involves truncation. Combined with SQLite's `REAL` type (8-byte IEEE 754 float, ~15 digits of precision), individual items are accurate enough, but the accumulation across conversions and summation surfaces visible errors.
|
||||
dnd-kit's `SortableContext` derives its order from React state. When the order is stored in React Query's cache rather than local React state, there is a timing mismatch: dnd-kit reads the list order from the cache after the drop animation, but the cache update triggers a React re-render cycle that arrives one or two frames late. The drop animation briefly shows the item at its original position before the re-render reflects the new order. This is a known, documented issue in dnd-kit (GitHub Discussion #1522, Issue #921) that specifically affects React Query integrations.
|
||||
|
||||
**How to avoid:**
|
||||
1. Store weights in grams as the canonical unit -- this is already done. Good.
|
||||
2. Convert only at the display boundary (the `formatWeight` function in `lib/formatters.ts`). Never convert grams to another unit, let the user edit, and convert back.
|
||||
3. When the user inputs in oz/lb/kg, convert to grams once on save and store. The edit form should always load the stored grams value and re-convert for display, never re-convert from a previously displayed value.
|
||||
4. Round only at the final display step, not during storage. Use `Number(value.toFixed(1))` for display, never for the stored value.
|
||||
5. For totals, compute `SUM(weight_grams)` in SQL (already done), then convert the total to display units once. Do not sum converted per-item display values.
|
||||
6. Consider changing `weight_grams` from `real` to `integer` to store milligrams (or tenths of grams) for sub-gram precision without floating-point issues. This is a larger migration but eliminates the class of errors entirely.
|
||||
Use a `tempItems` local state (`useState<Candidate[] | null>(null)`) alongside React Query. On `onDragEnd`, immediately set `tempItems` to the reordered array before calling `mutate()`. Render the candidate list from `tempItems ?? queryData.candidates`. In mutation `onSettled`, set `tempItems` to `null` to hand back control to React Query. This approach:
|
||||
- Prevents the flicker because the component re-renders from synchronous local state immediately
|
||||
- Avoids `useEffect` syncing (which adds extra renders and is error-prone)
|
||||
- Stays consistent with the existing React Query + Zustand pattern in the codebase
|
||||
- Handles drag cancellation cleanly (reset `tempItems` on `onDragCancel`)
|
||||
|
||||
Do not store the rank column data in the React Query `["threads", threadId]` cache key in a way that requires invalidation and refetch after reorder — this causes a round-trip delay that amplifies the flicker.
|
||||
|
||||
**Warning signs:**
|
||||
- Edit form pre-fills with a converted value and saves by reconverting that value
|
||||
- `formatWeight` is called before summation rather than after
|
||||
- Unit conversion is done in multiple places (client and server) with different rounding
|
||||
- Tests compare floating-point totals with `===` instead of tolerance-based comparison
|
||||
- Item visibly snaps back to original position for ~1 frame on drop
|
||||
- `onDragEnd` calls `mutate()` but uses no local state bridge
|
||||
- `setQueryData` is the only state update on drag end
|
||||
|
||||
**Phase to address:**
|
||||
Phase 1 (Weight unit selection) -- the conversion layer must be designed correctly from the start. Getting this wrong poisons every downstream feature (charts, setup totals, classification breakdowns).
|
||||
Candidate ranking phase — the `tempItems` pattern must be designed before building the drag UI, not retrofitted after noticing the flicker.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 2: Weight Classification Stored at Wrong Level
|
||||
### Pitfall 2: Rank Storage Using Integer Offsets Requires Bulk Writes
|
||||
|
||||
**What goes wrong:**
|
||||
Weight classification (base weight / worn / consumable) seems like a property of the item itself -- "my rain jacket is always worn weight." So the developer adds a `classification` column to the `items` table. But this is wrong: the same item can be classified differently in different setups. A rain jacket is "worn" in a summer bikepacking setup but "base weight" (packed in the bag) in a winter setup where you wear a heavier outer shell. By putting classification on the item, users cannot accurately model multiple setups with the same gear, which is the entire point of the setup feature.
|
||||
The most obvious approach for storing candidate rank order is adding a `sortOrder INTEGER` column to `thread_candidates` and storing 1, 2, 3... When the user drags candidate #2 to position #1, the naive fix is to update all subsequent candidates' `sortOrder` values to maintain contiguous integers. With 5 candidates, this is 5 UPDATE statements per drop. With rapid dragging, this creates a burst of writes where each intermediate position during the drag fires updates. If the app ever has threads with 10+ candidates (not uncommon for a serious gear decision), this becomes visible latency on every drag.
|
||||
|
||||
**Why it happens:**
|
||||
LighterPack and similar tools model classification at the list level (each list has its own classification per item), but when you look at the GearBox schema, the `setup_items` join table only has `(id, setup_id, item_id)`. It feels more natural to add a column to the item itself rather than to a join table, especially since the current `setup_items` table is minimal. The single-user context also makes it feel like "my items have fixed classifications."
|
||||
Integer rank storage feels natural and maps directly to how arrays work. The developer adds `ORDER BY sort_order ASC` to the candidate query and calls it done. The performance problem is only discovered when testing with a realistic number of candidates and a fast drag gesture.
|
||||
|
||||
**How to avoid:**
|
||||
Add `classification TEXT DEFAULT 'base'` to the `setup_items` table, not to `items`. This means:
|
||||
- The same item can have different classifications in different setups
|
||||
- Classification is optional and defaults to "base" (the most common case)
|
||||
- The `items` table stays generic -- classification is a setup-level concern
|
||||
- Existing `setup_items` rows get a sensible default via the migration
|
||||
- SQL aggregates for setup totals can easily group by classification: `SUM(CASE WHEN setup_items.classification = 'base' THEN items.weight_grams ELSE 0 END)`
|
||||
Use a `sortOrder REAL` column (floating-point) with fractional indexing — when inserting between positions A and B, assign `(A + B) / 2`. This means a drag requires only a single UPDATE for the moved item. Only trigger a full renumber (resetting all candidates to integers 1000, 2000, 3000... or similar spaced values) when the float precision degrades (approximately after 50+ nested insertions, unlikely in practice for this app). Start values at 1000, 5000 increments to give ample room.
|
||||
|
||||
If classification is also useful outside of setups (e.g., in the collection view for a general breakdown), add it as an optional `defaultClassification` on `items` that serves as a hint when adding items to setups, but the authoritative classification is always on `setup_items`.
|
||||
For GearBox's use case (typically 2-8 candidates per thread), integer storage is workable, but the fractional approach is cleaner and avoids the bulk-write problem entirely. The added complexity is minimal: one line of math in the service layer.
|
||||
|
||||
Regardless of storage strategy, add an index: `CREATE INDEX ON thread_candidates (thread_id, sort_order)`.
|
||||
|
||||
**Warning signs:**
|
||||
- `classification` column added to `items` table
|
||||
- Setup detail view shows classification but cannot be different per setup
|
||||
- Weight breakdown chart shows the same classification for an item across all setups
|
||||
- No way to classify an item as "worn" in one setup and "base" in another
|
||||
- `sortOrder` column uses `integer()` type in Drizzle schema
|
||||
- Reorder service function issues multiple UPDATE statements in a loop
|
||||
- No transaction wrapping the bulk update
|
||||
- Each drag event (not just the final drop) triggers a service call
|
||||
|
||||
**Phase to address:**
|
||||
Phase 2 (Weight classification) -- this is the single most important schema decision in v1.2. Getting it wrong requires migrating data out of the `items` table into `setup_items` later, which means reconciling possibly-different classifications that users already set.
|
||||
Schema and service design phase for candidate ranking — the storage strategy must be chosen before building the sort UI, as changing from integer to fractional later requires a migration.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 3: Search/Filter Implemented Server-Side for a Client-Side Dataset
|
||||
### Pitfall 3: Impact Preview Reads Stale Candidate Data
|
||||
|
||||
**What goes wrong:**
|
||||
The developer adds a `GET /api/items?search=tent&category=3` endpoint, sending filtered results from the server. This means:
|
||||
- Every keystroke fires an API request (or requires debouncing, adding latency)
|
||||
- The client's React Query cache for `["items"]` now contains different data depending on filter params, causing stale/inconsistent state
|
||||
- Category grouping in `CollectionView` breaks because the full list is no longer available
|
||||
- The existing `useTotals()` hook returns totals for all items, but the list shows filtered items -- a confusing mismatch
|
||||
The impact preview shows "+450g / +$89" next to each candidate — what this candidate would add to the selected setup. The calculation is: `(candidate.weightGrams - null) + setup.totalWeight`. But the candidate card data comes from the `["threads", threadId]` query cache, while the setup totals come from a separate `["setups", setupId]` query cache. These caches can be out of sync: the user edits a candidate's weight in one tab, invalidating the threads cache, but if the setup was fetched earlier and has not been refetched, the "current setup weight" baseline in the delta is stale. The preview shows a delta calculated against the wrong baseline.
|
||||
|
||||
The second failure mode: if `candidate.weightGrams` is `null` (not yet entered), displaying `+-- / +$89` is confusing. Users see "null delta" and assume the comparison is broken rather than understanding that the candidate has no weight data.
|
||||
|
||||
**Why it happens:**
|
||||
Server-side filtering is the "correct" pattern at scale, and most tutorials teach it that way. But GearBox is a single-user app where the entire collection fits comfortably in memory. The existing `useItems()` hook already fetches all items in one call and the collection view groups them client-side.
|
||||
Impact preview feels like pure computation — "just subtract two numbers." The developer writes it as a derived value from two props and does not think about cache coherence. The null case is often overlooked because the developer tests with complete candidate data.
|
||||
|
||||
**How to avoid:**
|
||||
Implement search and filter entirely on the client side:
|
||||
1. Keep `useItems()` fetching the full list (it already does)
|
||||
2. Add filter state (search query, category ID) as URL search params or React state in the collection page
|
||||
3. Filter the `items` array in the component using `Array.filter()` before grouping and rendering
|
||||
4. The totals bar should continue to show collection totals (unfiltered), not filtered totals -- or show both ("showing 12 of 47 items")
|
||||
5. Only move to server-side filtering if the collection exceeds ~500 items, which is far beyond typical for a single-user gear app
|
||||
|
||||
This preserves the existing caching behavior, requires zero API changes, and gives instant feedback on every keystroke.
|
||||
1. Derive the delta from data already co-located in one cache entry where possible. The thread detail query (`["threads", threadId]`) returns all candidates; the setup query (`["setups", setupId]`) returns items with weight. Compute the delta in the component using both: `delta = candidate.weightGrams - replacedItemWeight` where `replacedItemWeight` is taken from the currently loaded setup data.
|
||||
2. Use `useQuery` for setup data with the setup selector in the same component that renders the comparison, so both data sources are reactive.
|
||||
3. Handle null weight explicitly: show "-- (no weight data)" not "--g" for candidates without weights. Make the null state visually distinct from a zero delta.
|
||||
4. Do NOT make a server-side `/api/threads/:id/impact?setupId=:sid` endpoint that computes delta server-side — this creates a third cache entry to invalidate and adds network latency to what should be a purely client-side calculation.
|
||||
|
||||
**Warning signs:**
|
||||
- New query parameters added to `GET /api/items` endpoint
|
||||
- `useItems` hook accepts filter params, creating multiple cache entries
|
||||
- Search input has a debounce delay
|
||||
- Filtered view totals disagree with dashboard totals
|
||||
- Impact delta shows stale values after editing a candidate's weight
|
||||
- Null weight candidates show a numerical delta (treating null as 0)
|
||||
- Delta calculation is in a server route rather than a client-side derived value
|
||||
- Setup data is fetched via a different hook than the one used for candidate data, with no shared staleness boundary
|
||||
|
||||
**Phase to address:**
|
||||
Phase 1 (Search/filter) -- the decision to filter client-side vs server-side affects where state lives and must be decided before building the UI.
|
||||
Impact preview phase — establish the data flow (client-side derived from two existing queries) before building the UI so the stale-cache problem cannot arise.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 4: Candidate Status Transition Without Validation
|
||||
### Pitfall 4: Side-by-Side Comparison Breaks at Narrow Widths
|
||||
|
||||
**What goes wrong:**
|
||||
The existing thread system has a simple `status: "active" | "resolved"` on threads and no status on candidates. Adding candidate status tracking (researching -> ordered -> arrived) as a simple text column without transition validation allows impossible states: a candidate marked "arrived" in a thread that was already "resolved," or a candidate going from "arrived" back to "researching." Worse, the existing `resolveThread` function in `thread.service.ts` copies candidate data to create a collection item -- but does not check or update candidate status, so a "researching" candidate can be resolved as the winner (logically wrong, though the data flow works).
|
||||
The comparison view is built with a fixed two-or-three-column grid for the candidate cards. On a laptop at 1280px it looks great. On a narrower viewport or when the browser window is partially shrunk, the columns collapse to ~200px each, making the candidate name truncated, the weight/price badges unreadable, and the notes text invisible. The user cannot actually compare the candidates — the view that was supposed to help them decide becomes unusable.
|
||||
|
||||
GearBox's existing design philosophy is mobile-responsive, but comparison tables are inherently wide. The tension is real: side-by-side requires horizontal space that mobile cannot provide.
|
||||
|
||||
**Why it happens:**
|
||||
The current codebase uses plain strings for thread status with no validation layer. The developer follows the same pattern for candidate status: just a text column with no constraints. SQLite does not enforce enum values, so any string is accepted.
|
||||
Comparison views are usually mocked at full desktop width. Responsiveness is added as an afterthought, and the "fix" is often to stack columns vertically on mobile — which defeats the entire purpose of side-by-side comparison.
|
||||
|
||||
**How to avoid:**
|
||||
1. Define valid candidate statuses as a union type: `"researching" | "ordered" | "arrived"` in `schemas.ts`
|
||||
2. Add Zod validation for the status field with `.refine()` or `z.enum()` to reject invalid values at the API level
|
||||
3. Define valid transitions: `researching -> ordered -> arrived` (and optionally `* -> dropped`)
|
||||
4. In the service layer, validate that the requested status transition is valid before applying it (e.g., cannot go from "arrived" to "researching")
|
||||
5. When resolving a thread, do NOT require a specific candidate status -- the user may resolve with a "researching" candidate if they decide to buy it outright. But DO update all non-winner candidates to a terminal state like "dropped" in the same transaction.
|
||||
6. Add a check in `resolveThread`: if the thread is already resolved, reject the operation (this check already exists in the current code -- good)
|
||||
1. Build the comparison view as a horizontally scrollable container on mobile (`overflow-x: auto`). Do not collapse to vertical stack — comparing stacked items is cognitively equivalent to switching between detail pages.
|
||||
2. Limit the number of simultaneously compared candidates to 3 (or at most 4). Comparing 8 candidates side-by-side is unusable regardless of screen size.
|
||||
3. Use a minimum column width (e.g., `min-width: 200px`) so the container scrolls horizontally before the column content becomes illegible.
|
||||
4. Sticky first column for candidate names when scrolling horizontally, so the user always knows which column they are reading.
|
||||
5. Test at 768px viewport width before considering the feature done.
|
||||
|
||||
**Warning signs:**
|
||||
- Candidate status is a plain `text()` column with no Zod enum validation
|
||||
- No transition validation in the update candidate service
|
||||
- `resolveThread` does not update non-winner candidate statuses
|
||||
- UI allows arbitrary status changes via a dropdown with no constraints
|
||||
- Comparison grid uses percentage widths that collapse below 150px
|
||||
- No horizontal scroll on the comparison container
|
||||
- Mobile viewport shows columns stacked vertically
|
||||
- Candidate name or weight badges are truncated without tooltip
|
||||
|
||||
**Phase to address:**
|
||||
Phase 3 (Candidate status tracking) -- must be designed with awareness of the existing thread resolution flow in `thread.service.ts`. The status field and transition logic should be added together, not incrementally.
|
||||
Side-by-side comparison UI phase — responsive behavior must be designed in, not retrofitted. The minimum column width and scroll container decision shapes the entire component structure.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 5: Weight Distribution Chart Diverges from Displayed Totals
|
||||
### Pitfall 5: Pros/Cons Fields Stored as Free Text in Column, Not Structured
|
||||
|
||||
**What goes wrong:**
|
||||
The weight distribution chart (e.g., a donut chart showing weight by category or by classification) computes its data from one source, while the totals bar and setup detail page compute from another. The chart might use client-side summation of displayed (rounded) values while the totals use SQL `SUM()`. Or the chart uses the `useTotals()` hook data while the setup page computes totals inline (as `$setupId.tsx` currently does on lines 53-61). These different computation paths produce different numbers for the same data, and when a chart slice says "Shelter: 2,450g" but the category header says "Shelter: 2,451g," users lose trust.
|
||||
Pros and cons per candidate are stored as two free-text columns: `pros TEXT` and `cons TEXT` on `thread_candidates`. The user types a multi-line blob of text into each field. The comparison view renders them as raw text blocks next to each other. Two problems emerge:
|
||||
- Formatting: the comparison view cannot render individual pro/con bullet points because the data is unstructured blobs
|
||||
- Length: one candidate has a 500-word "pros" essay; another has two words. The comparison columns have wildly unequal heights, making the side-by-side comparison visually chaotic and hard to scan
|
||||
|
||||
The deeper problem: free text in a comparison context produces noise, not signal. Users write "it's really lightweight and packable and the color options are nice" when what the comparison view needs is scannable bullet points.
|
||||
|
||||
**Why it happens:**
|
||||
The codebase already has two computation paths for totals: `totals.service.ts` computes via SQL aggregates, and the setup detail page computes via JavaScript reduce on the client. These happen to agree now because there is no unit conversion, but adding unit display and classification filtering creates more opportunities for divergence.
|
||||
Adding two text columns to `thread_candidates` is the simplest possible implementation. The developer tests it with neat, short text and it looks fine. The UX failure is only visible when a real user writes the way real users write.
|
||||
|
||||
**How to avoid:**
|
||||
1. Establish a single source of truth for all weight computations: the SQL aggregate in the service layer.
|
||||
2. For chart data, create a dedicated endpoint or extend `GET /api/totals` to return breakdowns by category AND by classification (for setups). Do not recompute in the chart component.
|
||||
3. For setup-specific charts, extend `getSetupWithItems` to return pre-computed breakdowns, or compute them from the setup's item list using a shared utility function that is used by both the totals display and the chart.
|
||||
4. Unit conversion happens once, at the display layer, using the same `formatWeight` function everywhere.
|
||||
5. Write a test that compares the chart data source against the totals data source and asserts they agree.
|
||||
1. Store pros/cons as newline-delimited strings, not markdown or JSON. The UI splits on newlines and renders each line as a bullet. Simple, no parsing, no migration complexity.
|
||||
2. In the form, use a `<textarea>` with a placeholder of "one item per line." Show a character count.
|
||||
3. In the comparison view, render each newline-delimited entry as its own row, so columns stay scannable. Use a max of 5 bullet points per field; truncate with "show more" if longer.
|
||||
4. Cap `pros` and `cons` field length at 500 characters in the Zod schema to prevent essay-length blobs.
|
||||
5. The comparison view should truncate to the first 3 bullets when in compact comparison mode, with expand option.
|
||||
|
||||
**Warning signs:**
|
||||
- Chart component does its own `reduce()` on item data instead of using the same data as the totals display
|
||||
- Two different API endpoints return weight totals for the same scope and the values differ by small amounts
|
||||
- Chart labels show different precision than text displays (chart: "2.4 kg", header: "2,451 g")
|
||||
- No shared utility function for weight summation
|
||||
- `pros TEXT` and `cons TEXT` added to schema with no length constraint
|
||||
- Comparison view renders `{candidate.pros}` as a raw string in a `<p>` tag
|
||||
- One candidate's pros column is 3x taller than another's, making row alignment impossible
|
||||
- Form shows a full-height textarea with no guidance on format
|
||||
|
||||
**Phase to address:**
|
||||
Phase 3 (Weight distribution visualization) -- but the single-source-of-truth pattern should be established in Phase 1 when refactoring formatters for unit selection.
|
||||
Both the ranking schema phase (when pros/cons columns are added) and the comparison UI phase (when the rendering decision is made). The newline-delimited format must be decided at schema design time.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 6: Schema Migration Breaks Test Helper
|
||||
### Pitfall 6: Impact Preview Compares Against Wrong Setup Total When Item Would Be Replaced
|
||||
|
||||
**What goes wrong:**
|
||||
GearBox's test infrastructure uses a manual `createTestDb()` function in `tests/helpers/db.ts` that creates tables with raw SQL `CREATE TABLE` statements instead of using Drizzle's migration system. When adding new columns (e.g., `classification` to `setup_items`, `status` to `thread_candidates`, `weight_unit` to `settings`), the developer updates `src/db/schema.ts` and runs `bun run db:generate` + `bun run db:push`, but forgets to update the test helper's CREATE TABLE statements. All tests pass in the test database (which has the old schema) while the real database has the new schema -- or worse, tests fail with cryptic column-not-found errors and the developer wastes time debugging the wrong thing.
|
||||
The impact preview shows the delta for each candidate as if the candidate would be *added* to the setup. But the real use case is: "I want to replace my current tent with one of these candidates — which one saves the most weight?" The user expects the delta to reflect `candidateWeight - currentItemWeight`, not just `+candidateWeight`.
|
||||
|
||||
When the delta is calculated as a pure addition (no replacement), a 500g candidate looks like "+500g" even though the item it replaces weighs 800g, meaning it would actually save 300g. The user sees a positive delta and dismisses the candidate when they should pick it.
|
||||
|
||||
**Why it happens:**
|
||||
The test helper duplicates the schema definition in raw SQL rather than deriving it from the Drizzle schema. This is a known pattern in the codebase (documented in CLAUDE.md: "When adding schema columns, update both `src/db/schema.ts` and the test helper's CREATE TABLE statements"). But under the pressure of adding multiple schema changes across several features, it is easy to miss one table or one column.
|
||||
"Impact" is ambiguous. The developer defaults to "how much weight does this add?" because that calculation is simpler (no need to identify which existing item is being replaced). The replacement case requires the user to specify which item in the setup would be swapped out, which feels like additional UX complexity.
|
||||
|
||||
**How to avoid:**
|
||||
1. **Every schema change PR must include the corresponding test helper update.** Add this as a checklist item in the development workflow.
|
||||
2. Consider writing a simple validation test that compares the columns in `createTestDb()` tables against the Drizzle schema definition, failing if they diverge. This catches the problem automatically.
|
||||
3. For v1.2, since multiple schema changes are landing (classification on setup_items, status on candidates, possibly weight_unit in settings), batch the test helper update and verify all changes in one pass.
|
||||
4. Long-term: investigate using Drizzle's `migrate()` with in-memory SQLite to eliminate the duplication entirely.
|
||||
1. Support both modes: "add to setup" (+delta) and "replace item" (delta = candidate - replaced item). Make the mode selection explicit in the UI.
|
||||
2. Default to "add" mode if no item in the setup shares the same category as the thread. Default to "replace" mode if an item with the same category exists — offer it as a pre-populated suggestion ("Replaces: Big Agnes Copper Spur 2? Change").
|
||||
3. The replacement item selector should be a dropdown filtered to setup items in the same category, defaulting to the most likely match.
|
||||
4. If no setup is selected, show raw candidate weight rather than a delta — do not calculate a delta against zero.
|
||||
|
||||
**Warning signs:**
|
||||
- Schema column added to `schema.ts` but not to `tests/helpers/db.ts`
|
||||
- Tests pass locally but queries fail at runtime
|
||||
- New service function works in the app but throws in tests
|
||||
- Test database has fewer columns than production database
|
||||
- Delta is always positive (never shows weight savings)
|
||||
- No replacement item selector in the impact preview UI
|
||||
- Thread category is not used to suggest a candidate's likely replacement item
|
||||
- Delta is calculated as `candidate.weightGrams` with no baseline
|
||||
|
||||
**Phase to address:**
|
||||
Every phase that touches the schema. Must be addressed in Phase 1 (unit settings), Phase 2 (classification on setup_items), and Phase 3 (candidate status). Each phase should verify test helper parity as a completion criterion.
|
||||
Impact preview design phase — the "add vs replace" distinction must be designed before building the service layer, because "add" and "replace" produce fundamentally different calculations and different UI affordances.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 7: Weight Unit Preference Stored Wrong, Applied Wrong
|
||||
### Pitfall 7: Schema Change Adds Columns Without Updating Test Helper
|
||||
|
||||
**What goes wrong:**
|
||||
The developer stores the user's preferred weight unit as a setting (using the existing `settings` table with key-value pairs). But then applies it inconsistently: the collection page shows grams, the setup page shows ounces, the chart shows kilograms. Or the setting is read once on page load and cached in Zustand, so changing the preference requires a page refresh. Or the setting is read on every render, causing a flash of "g" before the "oz" preference loads.
|
||||
v1.3 requires adding `sortOrder`, `pros`, and `cons` to `thread_candidates`. The developer updates `src/db/schema.ts`, runs `bun run db:push`, and builds the feature. Tests fail with cryptic "no such column" errors — or worse, tests pass silently because they do not exercise the new columns, while the real database has them.
|
||||
|
||||
This pitfall already existed in v1.2 (documented in the previous PITFALLS.md) and the test helper (`tests/helpers/db.ts`) uses raw CREATE TABLE SQL that must be manually kept in sync.
|
||||
|
||||
**Why it happens:**
|
||||
The `settings` table is a key-value store with no type safety. The preference is a string like `"oz"` that must be parsed and applied in many places: `formatWeight` in formatters, chart labels, totals bar, setup detail, item cards, category headers. Missing any one of these locations creates an inconsistency.
|
||||
Under time pressure, the developer focuses on the feature and forgets the test helper update. The error only surfaces when the new service function is called in a test. The CLAUDE.md documents this requirement, but it is easy to miss in the flow of development.
|
||||
|
||||
**How to avoid:**
|
||||
1. Store the preference in the `settings` table as `{ key: "weightUnit", value: "g" | "oz" | "lb" | "kg" }`.
|
||||
2. Create a `useWeightUnit()` hook that wraps `useSettings()` and returns the parsed unit with a fallback to `"g"`.
|
||||
3. Modify `formatWeight` to accept a unit parameter: `formatWeight(grams, unit)`. This is a single function used everywhere, so changing it propagates automatically.
|
||||
4. Do NOT store converted values anywhere -- always store grams, convert at display time.
|
||||
5. Use React Query for the settings fetch so the preference is cached and shared across components. When the user changes their preference, invalidate `["settings"]` and all displays update simultaneously via React Query's reactivity.
|
||||
6. Handle the loading state: show raw grams (or a loading skeleton) until the preference is loaded. Do not flash a different unit.
|
||||
For every schema change in v1.3, update `tests/helpers/db.ts` in the same commit:
|
||||
- `thread_candidates`: add `sort_order REAL DEFAULT 0`, `pros TEXT`, `cons TEXT`
|
||||
- Run `bun test` immediately after schema + helper update, before writing any other code
|
||||
|
||||
Consider writing a schema-parity test: compare the columns returned by `PRAGMA table_info(thread_candidates)` against a known expected list, failing if they differ. This catches the test-helper-out-of-sync problem automatically.
|
||||
|
||||
**Warning signs:**
|
||||
- `formatWeight` does not accept a unit parameter -- it is hardcoded to `"g"`
|
||||
- Weight unit preference is stored in Zustand instead of React Query (settings endpoint)
|
||||
- Some components use `formatWeight` and some inline their own formatting
|
||||
- Changing the unit preference does not update all visible weights without a page refresh
|
||||
- Tests failing with `SqliteError: no such column`
|
||||
- New service function works in the running app but throws in `bun test`
|
||||
- `bun run db:push` was run but `bun test` was not run afterward
|
||||
- `tests/helpers/db.ts` has fewer columns than `src/db/schema.ts`
|
||||
|
||||
**Phase to address:**
|
||||
Phase 1 (Weight unit selection) -- this is foundational infrastructure. The `formatWeight` refactor and `useWeightUnit` hook must exist before building any other feature that displays weight.
|
||||
Every schema-touching phase of v1.3. The candidate ranking schema phase (sortOrder, pros, cons) is the primary risk. Check test helper parity as an explicit completion criterion.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 8: Comparison View Includes Resolved Candidates
|
||||
|
||||
**What goes wrong:**
|
||||
After a thread is resolved (winner picked), the thread's candidates still exist in the database. The comparison view, if it loads candidates from the existing `getThreadWithCandidates` response without filtering, will display the resolved winner alongside all losers — including the now-irrelevant candidates. A user revisiting a resolved thread to check why they picked Option A sees all candidates re-listed in the comparison view with no indication of which was selected, creating confusion.
|
||||
|
||||
The secondary problem: if the app allows drag-to-reorder ranking on a resolved thread, a user could accidentally fire rank-update mutations on a thread that should be read-only.
|
||||
|
||||
**Why it happens:**
|
||||
The comparison view and ranking components are built for active threads and tested only with active threads. Resolved thread behavior is not considered during design.
|
||||
|
||||
**How to avoid:**
|
||||
1. Check `thread.status === "resolved"` before rendering the comparison/ranking UI. For resolved threads, render a read-only summary: "You chose [winner name]" with the winning candidate highlighted and others shown as non-interactive.
|
||||
2. Disable drag-to-reorder on resolved threads entirely — don't render the drag handles.
|
||||
3. In the impact preview, disable the "Impact on Setup" panel for resolved threads and instead show "Added to collection on [date]" for the winning candidate.
|
||||
4. The API route for rank updates should reject requests for resolved threads (return 400 with "Thread is resolved").
|
||||
|
||||
**Warning signs:**
|
||||
- Comparison/ranking UI renders identically for active and resolved threads
|
||||
- Drag handles are visible on resolved thread candidates
|
||||
- No `thread.status` check in the comparison view component
|
||||
- Resolved threads accept rank update mutations
|
||||
|
||||
**Phase to address:**
|
||||
Comparison UI phase and ranking phase — both must include a resolved-thread guard. This is a correctness issue, not just a UX issue, because drag mutations on resolved threads corrupt state.
|
||||
|
||||
---
|
||||
|
||||
@@ -206,24 +234,29 @@ Shortcuts that seem reasonable but create long-term problems.
|
||||
|
||||
| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|
||||
|----------|-------------------|----------------|-----------------|
|
||||
| Adding classification to `items` instead of `setup_items` | Simpler schema, no join table changes | Cannot have different classifications per setup; future migration required | Never -- the per-setup model is the correct one |
|
||||
| Client-side unit conversion on both read and write paths | Simple bidirectional conversion | Rounding drift over edit cycles, inconsistent totals | Never -- convert once on write, display-only on read |
|
||||
| Separate chart data computation from totals computation | Faster chart development, no API changes | Numbers disagree between chart and text displays | Only if a shared utility function ensures identical computation |
|
||||
| Hardcoding chart library colors per category | Quick to implement | Colors collide when user adds categories; no dark mode support | MVP only if using a predictable color generation function is planned |
|
||||
| Adding candidate status without transition validation | Faster to implement | Invalid states accumulate, resolve logic has edge cases | Only if validation is added before the feature ships to production |
|
||||
| Debouncing search instead of client-side filter | Familiar pattern from server-filtered apps | Unnecessary latency, complex cache management | Never for this app's scale (sub-500 items) |
|
||||
| Integer `sortOrder` instead of fractional | Simple schema | Bulk UPDATE on every reorder; bulk write latency with 10+ candidates | Acceptable only if max candidates per thread is enforced at 5 or fewer |
|
||||
| Server-side delta calculation endpoint | Simpler client code | Third cache entry to invalidate; network round-trip on every setup selection change | Never — the calculation is two subtractions using data already in client cache |
|
||||
| Pros/cons as unstructured free-text blobs | Zero schema complexity | Comparison view cannot render bullets; columns misalign | Never for comparison display — use newline-delimited format from day one |
|
||||
| Comparison grid with `overflow: hidden` on narrow viewports | Avoids horizontal scroll complexity | Comparison becomes unreadable on laptop with panels open; critical feature breaks | Never — horizontal scroll is the correct behavior for comparison tables |
|
||||
| Rendering comparison for resolved threads without guard | Simpler component logic | Users can drag-reorder resolved threads, corrupting state | Never — the resolved-thread guard is a correctness requirement |
|
||||
| `DragOverlay` using same component as `useSortable` | Less component code | ID collision in dnd-kit causes undefined behavior during drag | Never — dnd-kit explicitly requires a separate presentational component for DragOverlay |
|
||||
|
||||
---
|
||||
|
||||
## Integration Gotchas
|
||||
|
||||
Since v1.2 is adding features to an existing system rather than integrating external services, these are internal integration points where new features interact with existing ones.
|
||||
Common mistakes when connecting new v1.3 features to existing v1.2 systems.
|
||||
|
||||
| Integration Point | Common Mistake | Correct Approach |
|
||||
|-------------------|----------------|------------------|
|
||||
| Search/filter + category grouping | Filtering items breaks the existing category-grouped layout because the group headers disappear when no items match | Filter within groups: show category headers only for groups with matching items. Empty groups should hide, not show "no items." |
|
||||
| Weight classification + existing setup totals | Adding classification changes how totals are computed (base weight vs total weight), but existing setup list cards show `totalWeight` which was previously "everything" | Keep `totalWeight` as the sum of all items. Add `baseWeight` as a new computed field (sum of items where classification = 'base'). Show both in the setup detail view. |
|
||||
| Candidate status + thread resolution | Adding status to candidates but not updating `resolveThread` to handle it | The `resolveThread` transaction must set winner status to a terminal state and non-winners to "dropped." New candidates added to an already-resolved thread should be rejected. |
|
||||
| Unit selection + React Query cache | Changing the weight unit preference does not invalidate the items cache because items are stored in grams regardless | The unit preference is a display concern, not a data concern. Do NOT invalidate items/totals on unit change. Just re-render with the new unit. Ensure `formatWeight` is called reactively, not cached. |
|
||||
| Weight chart + empty/null weights | Chart component crashes or shows misleading data when items have `null` weight | Filter out items with null weight from chart data. Show a note like "3 items excluded (no weight recorded)." Never treat null as 0 in a chart -- that makes the chart lie. |
|
||||
| Ranking + React Query | Using `setQueryData` alone for optimistic reorder, causing flicker | Maintain `tempItems` local state in the drag component; render from `tempItems ?? queryData.candidates`; clear on `onSettled` |
|
||||
| Impact preview + weight unit | Computing delta in grams but displaying with `formatWeight` that expects the stored unit | Delta is always computed in grams (raw stored values); apply `formatWeight(delta, unit)` once at display time, same pattern as all other weight displays |
|
||||
| Impact preview + null weights | Treating `null` weightGrams as 0 in delta calculation | Show "-- (no weight data)" explicitly; never pass null to arithmetic; guard with `candidate.weightGrams != null && setup.totalWeight != null` |
|
||||
| Pros/cons + thread resolution | Pros/cons text copied to collection item on resolve | Do NOT copy pros/cons to the items table — these are planning notes, not collection metadata. `resolveThread` in `thread.service.ts` should remain unchanged |
|
||||
| Rank order + existing `getThreadWithCandidates` | Adding `ORDER BY sort_order` to `getThreadWithCandidates` changes the order of an existing query used by other components | Add `sort_order` to the SELECT and ORDER BY in `getThreadWithCandidates`. Audit all consumers of this query to verify they are unaffected by ordering change (the candidate cards already render in whatever order the query returns) |
|
||||
| Comparison view + `isActive` prop | `CandidateCard.tsx` uses `isActive` to show/hide the "Winner" button. Comparison view must not show "Winner" button inline if comparison has its own resolve affordance | Pass `isActive={false}` to `CandidateCard` when rendering inside comparison view, or create a separate `CandidateComparisonCard` presentational component that omits action buttons |
|
||||
|
||||
---
|
||||
|
||||
## Performance Traps
|
||||
|
||||
@@ -231,11 +264,12 @@ Patterns that work at small scale but fail as usage grows.
|
||||
|
||||
| Trap | Symptoms | Prevention | When It Breaks |
|
||||
|------|----------|------------|----------------|
|
||||
| Re-rendering entire collection on search keystroke | UI jank on every character typed in search box | Use `useMemo` to memoize the filtered list; ensure `ItemCard` is memoized with `React.memo` | 100+ items with images |
|
||||
| Chart re-renders on every parent state change | Chart animation restarts on unrelated state updates (e.g., opening a panel) | Memoize chart data computation with `useMemo`; wrap chart component in `React.memo`; use `isAnimationActive={false}` after initial render | Any chart library with entrance animations |
|
||||
| Recharts SVG rendering with many category slices | Donut chart becomes sluggish with 20+ categories, each with a tooltip and label | Limit chart to top N categories by weight, group the rest into "Other." Recharts is SVG-based, so keep segments under ~15. | 20+ categories (unlikely for single user, but possible) |
|
||||
| Fetching settings on every component that displays weight | Waterfall of settings requests, or flash of unconverted weights | Use React Query with `staleTime: Infinity` for settings (they change rarely). Prefetch settings at app root. | First load of any page with weights |
|
||||
| Computing classification breakdown per-render | Expensive reduce operations on every render cycle | Compute once in `useMemo` keyed on the items array and classification data | Setups with 50+ items (common for full bikepacking lists) |
|
||||
| Bulk integer rank updates on every drag | Visible latency after each drop; multiple UPDATE statements per drag; SQLite write lock held | Use fractional `sortOrder REAL` so only the moved item requires an UPDATE | 8+ candidates per thread with rapid dragging |
|
||||
| Comparison view fetching all candidates for all threads | Slow initial load; excessive memory for large thread lists | Comparison view uses the already-loaded `["threads", threadId]` query; never fetches candidates outside the active thread's query | 20+ threads with 5+ candidates each |
|
||||
| Sync rank updates on every `dragOver` event (not just `dragEnd`) | Thousands of UPDATE mutations during a single drag; server overwhelmed; UI lags | Persist rank only on `onDragEnd` (drop), never on `onDragOver` (in-flight hover) | Any usage — `onDragOver` fires on every cursor pixel moved |
|
||||
| `useQuery` for setups list inside impact preview component | N+1 query pattern: each candidate card fetches its own setup list | Lift setup list query to the thread detail page level; pass selected setup as prop or context | 3+ candidates in comparison view |
|
||||
|
||||
---
|
||||
|
||||
## Security Mistakes
|
||||
|
||||
@@ -243,9 +277,11 @@ Domain-specific security issues beyond general web security.
|
||||
|
||||
| Mistake | Risk | Prevention |
|
||||
|---------|------|------------|
|
||||
| Candidate status field accepts arbitrary strings | SQLite text column accepts anything; UI may display unexpected values or XSS payloads in status badges | Validate status against enum in Zod schema. Reject unknown values at API level. Use `z.enum(["researching", "ordered", "arrived"])`. |
|
||||
| Search query used in raw SQL LIKE | SQL injection if search string is interpolated into query (unlikely with Drizzle ORM but possible in raw SQL aggregates) | Use Drizzle's `like()` or `ilike()` operators which parameterize automatically. Never use template literals in `sql\`\`` with unsanitized user input. |
|
||||
| Unit preference allows arbitrary values | Settings table stores any string; a crafted value could break formatWeight or cause display issues | Validate unit against `z.enum(["g", "oz", "lb", "kg"])` both on read and write. Use a typed constant for the allowed values. |
|
||||
| `sortOrder` accepts any float value | Malformed values like `NaN`, `Infinity`, or extremely large floats stored in `sort_order` column, corrupting order | Validate `sortOrder` as a finite number in Zod schema: `z.number().finite()`. Reject `NaN` and `Infinity` at API boundary |
|
||||
| Pros/cons fields with no length limit | Users or automated input can store multi-kilobyte text blobs, inflating the database and slowing candidate queries | Cap at 500 characters per field in Zod: `z.string().max(500).optional()` |
|
||||
| Rank update endpoint accepts any candidateId | A crafted request can reorder candidates from a different thread by passing a candidateId that belongs to another thread | In the rank update service, verify `candidate.threadId === threadId` before applying the update — same pattern as existing `resolveThread` validation |
|
||||
|
||||
---
|
||||
|
||||
## UX Pitfalls
|
||||
|
||||
@@ -253,28 +289,33 @@ Common user experience mistakes in this domain.
|
||||
|
||||
| Pitfall | User Impact | Better Approach |
|
||||
|---------|-------------|-----------------|
|
||||
| Search clears when switching tabs (gear/planning/setups) | User searches for "tent," switches to planning to check threads, switches back and search is gone | Persist search query as a URL search parameter (`?tab=gear&q=tent`). TanStack Router already handles tab via search params. |
|
||||
| Unit selection buried in settings page | User cannot quickly toggle between g and oz when comparing products listed in different units | Add a unit toggle/selector directly in the weight display area (e.g., in the TotalsBar or a small dropdown next to weight values). Keep global preference in settings, but allow quick access. |
|
||||
| Classification picker adds friction to setup composition | User must classify every item when adding it to a setup, turning a quick "add to loadout" into a tedious process | Default all items to "base" classification. Allow bulk reclassification. Show classification as an optional second step after composing the setup. |
|
||||
| Chart with no actionable insight | A pie chart showing "Shelter: 40%, Sleep: 25%, Cooking: 20%" is pretty but does not help the user make decisions | Pair the chart with a list sorted by weight. Highlight the heaviest category. If possible, show how the breakdown compares to "typical" or to other setups. At minimum, make chart segments clickable to filter to that category. |
|
||||
| Status badges with no timestamps | User sees "ordered" but cannot remember when they ordered, or whether it has been suspiciously long | Store status change timestamps. Show relative time ("ordered 3 days ago"). Highlight statuses that have been stale too long ("ordered 30+ days ago -- still waiting?"). |
|
||||
| Filter resets feel destructive | User applies multiple filters (category + search), then accidentally clears one and loses the other | Show active filters as dismissible chips/pills above the list. Each filter is independently clearable. A "clear all" button resets everything. |
|
||||
| Comparison table loses column headers on scroll | User scrolls down to see notes/pros/cons and forgets which column is which candidate | Sticky column headers with candidate name, image thumbnail, and weight. Use `position: sticky; top: 0` on the header row |
|
||||
| Delta shows raw gram values when user prefers oz | Impact preview shows "+450g" to a user who has set their unit to oz | Apply `formatWeight(delta, unit)` using the `useWeightUnit()` hook, same as all other weight displays in the app |
|
||||
| Drag-to-reorder with no visual rank indicator | After ranking, it is unclear that the order matters or that #1 is the "top pick" | Show rank numbers (1, 2, 3...) as badges on each candidate card when in ranking mode. Update numbers live during drag |
|
||||
| Pros/cons fields empty by default in comparison view | Comparison table shows empty cells next to populated ones, making the comparison feel sparse and incomplete | Show a subtle "Add pros/cons" prompt in empty cells when the thread is active. In read-only resolved view, hide the pros/cons section entirely if no candidate has data |
|
||||
| Impact preview setup selector defaults to no setup | User arrives at comparison view and sees no impact numbers because no setup is pre-selected | Default the setup selector to the most recently viewed/modified setup. Persist the last-selected setup in `sessionStorage` or a URL param |
|
||||
| Removing a candidate clears comparison selection | User has candidates A, B, C in comparison; deletes C; comparison resets entirely | Comparison state (which candidates are selected) should be stored in local component state keyed by candidate ID. On delete, simply remove that ID from the selection |
|
||||
|
||||
---
|
||||
|
||||
## "Looks Done But Isn't" Checklist
|
||||
|
||||
Things that appear complete but are missing critical pieces.
|
||||
|
||||
- [ ] **Search/filter:** Often missing keyboard shortcut (Cmd/Ctrl+K to focus search) -- verify search is easily accessible without mouse
|
||||
- [ ] **Search/filter:** Often missing empty state for "no results" -- verify a helpful message appears when search matches nothing, distinct from "collection is empty"
|
||||
- [ ] **Weight classification:** Often missing the per-setup model -- verify the same item can have different classifications in different setups
|
||||
- [ ] **Weight classification:** Often missing "unclassified" handling -- verify items with no classification default to "base" in all computations
|
||||
- [ ] **Weight chart:** Often missing null-weight items -- verify items without weight data are excluded from chart with a visible note, not silently treated as 0g
|
||||
- [ ] **Weight chart:** Often missing responsiveness -- verify chart renders correctly on mobile widths (Recharts needs `ResponsiveContainer` wrapper)
|
||||
- [ ] **Candidate status:** Often missing transition validation -- verify a candidate cannot go from "arrived" back to "researching"
|
||||
- [ ] **Candidate status:** Often missing integration with thread resolution -- verify resolving a thread updates all candidate statuses appropriately
|
||||
- [ ] **Unit selection:** Often missing consistent application -- verify every weight display in the app (cards, headers, totals, charts, setup detail, item picker) uses the selected unit
|
||||
- [ ] **Unit selection:** Often missing the edit form -- verify the item/candidate edit form shows weight in the selected unit and converts correctly on save
|
||||
- [ ] **Unit selection:** Often missing chart axis labels -- verify the chart shows the correct unit in labels and tooltips
|
||||
- [ ] **Drag-to-reorder:** Often missing drag handles — verify the drag affordance is visually distinct (grip icon), not just "drag anywhere on the card" which conflicts with the existing click-to-edit behavior
|
||||
- [ ] **Drag-to-reorder:** Often missing keyboard reorder fallback — verify candidates can be moved with arrow keys for accessibility (dnd-kit's `KeyboardSensor` must be added to `DndContext`)
|
||||
- [ ] **Drag-to-reorder:** Often missing flicker fix — verify dropping a candidate does not briefly snap back to original position (requires `tempItems` local state, not just `setQueryData`)
|
||||
- [ ] **Drag-to-reorder:** Often missing resolved-thread guard — verify drag handles are hidden and mutations are blocked on resolved threads
|
||||
- [ ] **Impact preview:** Often missing the null weight case — verify candidates with no weight show "-- (no weight data)" not "NaNg" or "+0g"
|
||||
- [ ] **Impact preview:** Often missing the replace-vs-add distinction — verify the user can specify which existing item would be replaced, not just see a pure addition delta
|
||||
- [ ] **Impact preview:** Often missing unit conversion — verify the delta respects `useWeightUnit()` and `useCurrency()`, not hardcoded to grams/USD
|
||||
- [ ] **Side-by-side comparison:** Often missing horizontal scroll on narrow viewports — verify the view is usable at 768px without column collapsing
|
||||
- [ ] **Side-by-side comparison:** Often missing sticky headers — verify candidate names remain visible when scrolling the comparison rows
|
||||
- [ ] **Pros/cons fields:** Often missing length validation — verify Zod schema caps the field and the textarea shows a character counter
|
||||
- [ ] **Pros/cons display:** Often missing newline-to-bullet rendering — verify newlines in the stored text render as bullet points in the comparison view, not as `\n` characters
|
||||
- [ ] **Schema changes:** Often missing test helper update — verify `tests/helpers/db.ts` includes `sort_order`, `pros`, and `cons` columns after the schema migration
|
||||
|
||||
---
|
||||
|
||||
## Recovery Strategies
|
||||
|
||||
@@ -282,13 +323,15 @@ When pitfalls occur despite prevention, how to recover.
|
||||
|
||||
| Pitfall | Recovery Cost | Recovery Steps |
|
||||
|---------|---------------|----------------|
|
||||
| Classification on items instead of setup_items | HIGH | Add classification column to setup_items. Write migration to copy item classification to all setup_item rows referencing it. Remove classification from items. Review all service queries. |
|
||||
| Rounding drift from bidirectional conversion | MEDIUM | Audit all items for drift (compare stored grams to expected values). Fix formatWeight to convert only at display. One-time data cleanup for items with suspicious fractional grams. |
|
||||
| Chart data disagrees with totals | LOW | Refactor chart to use the same data source as totals. Create shared utility. No data migration needed. |
|
||||
| Test helper out of sync with schema | LOW | Update CREATE TABLE statements in test helper. Run all tests. Fix any that relied on the old schema. |
|
||||
| Server-side search causing cache issues | MEDIUM | Revert to client-side filtering. Remove query params from useItems. May need to clear stale React Query cache entries with different keys. |
|
||||
| Candidate status without transitions | MEDIUM | Add transition validation to update endpoint. Audit existing candidates for invalid states. Write cleanup migration if needed. |
|
||||
| Unit preference inconsistently applied | LOW | Audit all weight display points. Ensure all use formatWeight with unit parameter. No data changes needed. |
|
||||
| Drag flicker due to no `tempItems` local state | LOW | Add `tempItems` state to the ranking component. Render from `tempItems ?? queryData.candidates`. No data migration needed. |
|
||||
| Integer `sortOrder` causing bulk updates | MEDIUM | Add Drizzle migration to change `sort_order` column type from INTEGER to REAL. Update existing rows to spaced values (1000, 2000, 3000...). Update service layer to use fractional logic. |
|
||||
| Delta treats null weight as 0 | LOW | Add null guards in the delta calculation component. No data changes needed. |
|
||||
| Pros/cons stored as unformatted blobs | LOW | No migration needed — the data is still correct. Update the rendering component to split on newlines. Add length validation to the Zod schema for new input. |
|
||||
| Comparison view visible on resolved threads | LOW | Add `if (thread.status === 'resolved') return <ResolvedView />` before rendering the comparison/ranking UI. Add 400 check in the rank update API route. |
|
||||
| Test helper out of sync with schema | LOW | Update CREATE TABLE statements in `tests/helpers/db.ts`. Run `bun test`. Fix any test that relied on the old column count. |
|
||||
| Rank update accepts cross-thread candidateId | LOW | Add `candidate.threadId !== threadId` guard in rank update service (same pattern as existing `resolveThread` guard). |
|
||||
|
||||
---
|
||||
|
||||
## Pitfall-to-Phase Mapping
|
||||
|
||||
@@ -296,27 +339,30 @@ How roadmap phases should address these pitfalls.
|
||||
|
||||
| Pitfall | Prevention Phase | Verification |
|
||||
|---------|------------------|--------------|
|
||||
| Rounding accumulation | Phase 1: Weight unit selection | `formatWeight` converts grams to display unit. Edit forms load grams from API, not from displayed value. Write test: edit an item 10 times without changes, weight stays identical. |
|
||||
| Classification at wrong level | Phase 2: Weight classification | `classification` column exists on `setup_items`, not `items`. Test: same item in two setups has different classifications. |
|
||||
| Server-side search for client data | Phase 1: Search/filter | No new API parameters on `GET /api/items`. Filter logic lives in `CollectionView` component. Test: search works instantly without network requests. |
|
||||
| Status without transition validation | Phase 3: Candidate status | Zod enum validates status values. Service rejects invalid transitions. Test: updating "arrived" to "researching" returns 400 error. |
|
||||
| Chart/totals divergence | Phase 3: Weight visualization | Chart data and totals bar use same computation path. Test: sum of chart segment values equals displayed total. |
|
||||
| Test helper desync | Every schema-changing phase | Each phase's PR includes updated test helper. CI test suite catches column mismatches. |
|
||||
| Unit preference inconsistency | Phase 1: Weight unit selection | All weight displays use `formatWeight(grams, unit)`. Test: change unit preference, verify all visible weights update without refresh. |
|
||||
| dnd-kit + React Query flicker | Candidate ranking phase | Drop a candidate, verify no snap-back. Add automated test: mock drag end, verify list order reflects drop position immediately. |
|
||||
| Bulk integer rank writes | Schema design for ranking | `sortOrder` column is `REAL` type in Drizzle schema. Service layer issues exactly one UPDATE per reorder. Test: reorder 5 candidates, verify only 1 DB write. |
|
||||
| Stale data in impact preview | Impact preview phase | Change a candidate's weight, verify delta updates immediately. Select a different setup, verify delta recalculates from new baseline. |
|
||||
| Comparison broken at narrow width | Comparison UI phase | Test at 768px viewport. Verify horizontal scroll is present and content is readable. No vertical stack of comparison columns. |
|
||||
| Pros/cons as unstructured blobs | Ranking schema phase (when columns added) | Verify Zod schema caps at 500 chars. Verify comparison view renders newlines as bullets. Test: enter 3-line pros text, verify 3 bullets rendered. |
|
||||
| Impact preview add vs replace | Impact preview design phase | Thread with same-category item in setup defaults to replace mode. Pure-add mode available as alternative. Test: replace mode shows negative delta when candidate is lighter. |
|
||||
| Comparison/rank on resolved threads | Both comparison and ranking phases | Verify drag handles are absent on resolved threads. Verify rank update API returns 400 for resolved thread. |
|
||||
| Test helper schema drift | Every schema-touching phase of v1.3 | After schema change, run `bun test` immediately. Zero test failures from column-not-found errors. |
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- [Weight conversion precision and rounding best practices](https://explore.st-aug.edu/exp/from-ounces-to-pounds-the-precision-behind-weight-conversions-heres-how-many-grams-equal-a-practical-pound) -- authoritative source on conversion factor precision
|
||||
- [Base weight classification definitions and community debates](https://thetrek.co/continental-divide-trail/how-to-easily-lower-your-base-weight-calculate-it-differently/) -- real-world examples of classification ambiguity
|
||||
- [LighterPack user classification errors](https://www.99boulders.com/lighterpack-tutorial) -- LighterPack's approach to base/worn/consumable
|
||||
- [Avoiding common mistakes with TanStack Query](https://www.buncolak.com/posts/avoiding-common-mistakes-with-tanstack-query-part-1/) -- anti-patterns with React Query caching
|
||||
- [TanStack Query discussions on filtering with cache](https://github.com/TanStack/query/discussions/1113) -- community patterns for client-side vs server-side filtering
|
||||
- [Recharts performance and limitations](https://blog.logrocket.com/best-react-chart-libraries-2025/) -- SVG rendering pitfalls, ResponsiveContainer requirement
|
||||
- [Drizzle ORM SQLite migration pitfalls](https://github.com/drizzle-team/drizzle-orm/issues/1313) -- data loss bug with push + add column
|
||||
- [State machine anti-patterns](https://rclayton.silvrback.com/use-state-machines) -- importance of explicit transition validation
|
||||
- [Ultralight gear tracker leaving LighterPack](https://trailsmag.net/blogs/hiker-box/ultralight-the-gear-tracking-app-i-m-leaving-lighterpack-for) -- community frustrations with existing tools
|
||||
- Direct codebase analysis of GearBox v1.1 (schema.ts, services, hooks, routes) -- existing patterns and integration points
|
||||
- [dnd-kit Discussion #1522: React Query + DnD flicker](https://github.com/clauderic/dnd-kit/discussions/1522) — `tempItems` solution for React Query cache flicker
|
||||
- [dnd-kit Issue #921: Sorting not working with React Query](https://github.com/clauderic/dnd-kit/issues/921) — Root cause of the state lifecycle mismatch
|
||||
- [dnd-kit Sortable Docs: OptimisticSortingPlugin](https://dndkit.com/concepts/sortable) — New API for handling optimistic reorder
|
||||
- [TanStack Query: Optimistic Updates guide](https://tanstack.com/query/v4/docs/react/guides/optimistic-updates) — `onMutate`/`onSettled` rollback patterns
|
||||
- [Fractional Indexing: Steveruiz.me](https://www.steveruiz.me/posts/reordering-fractional-indices) — Why fractional keys beat integer reorder for databases
|
||||
- [Fractional Indexing SQLite library](https://github.com/sqliteai/fractional-indexing) — Implementation reference for base62 lexicographic sort keys
|
||||
- [Baymard Institute: Comparison Tool Design](https://baymard.com/blog/user-friendly-comparison-tools) — Sticky headers, horizontal scroll, minimum column width for product comparison UX
|
||||
- [NN/G: Comparison Tables](https://www.nngroup.com/articles/comparison-tables/) — Avoid prose in comparison cells; use scannable structured values
|
||||
- [LogRocket: When comparison charts hurt UX](https://blog.logrocket.com/ux-design/feature-comparison-tips-when-not-to-use/) — Comparison table anti-patterns
|
||||
- Direct codebase analysis of GearBox v1.2 (schema.ts, thread.service.ts, setup.service.ts, CandidateCard.tsx, useSetups.ts, useCandidates.ts, tests/) — existing patterns, integration points, and established conventions
|
||||
|
||||
---
|
||||
*Pitfalls research for: GearBox v1.2 -- Collection Power-Ups (search/filter, weight classification, charts, candidate status, unit selection)*
|
||||
*Pitfalls research for: GearBox v1.3 — Research & Decision Tools (side-by-side comparison, impact preview, candidate ranking)*
|
||||
*Researched: 2026-03-16*
|
||||
|
||||
@@ -1,179 +1,198 @@
|
||||
# Technology Stack -- v1.2 Collection Power-Ups
|
||||
# Stack Research -- v1.3 Research & Decision Tools
|
||||
|
||||
**Project:** GearBox
|
||||
**Researched:** 2026-03-16
|
||||
**Scope:** Stack additions for search/filter, weight classification, weight distribution charts, candidate status tracking, weight unit selection
|
||||
**Scope:** Stack additions for side-by-side candidate comparison, setup impact preview, and drag-to-reorder candidate ranking with pros/cons
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Key Finding: Minimal New Dependencies
|
||||
## Key Finding: Zero New Dependencies
|
||||
|
||||
Four of five v1.2 features require **zero new libraries**. They are pure application logic built on top of the existing stack (Drizzle ORM filters, Zod schema extensions, Zustand state, React Query invalidation). The only decision point is whether to add a charting library for weight distribution visualization.
|
||||
All three v1.3 features are achievable with the existing stack. The drag-to-reorder feature, which would normally require a dedicated DnD library, is covered by `framer-motion`'s built-in `Reorder` component — already installed at v12.37.0 with React 19 support confirmed.
|
||||
|
||||
## New Dependency
|
||||
## Recommended Stack: Existing Technologies Only
|
||||
|
||||
### Charting: react-minimal-pie-chart
|
||||
### No New Dependencies Required
|
||||
|
||||
| Technology | Version | Purpose | Why |
|
||||
|------------|---------|---------|-----|
|
||||
| react-minimal-pie-chart | ^9.1.2 | Weight distribution donut/pie charts | Under 2kB gzipped. Supports pie, donut, loading, and completion chart types. SVG-based with CSS animations, hover/click interactions, custom label rendering. React 19 compatible (peerDeps explicitly include `^19`). Zero external dependencies. TypeScript native. |
|
||||
| Feature | Library Needed | Status |
|
||||
|---------|---------------|--------|
|
||||
| Side-by-side comparison view | None — pure layout/UI | Existing Tailwind CSS |
|
||||
| Setup impact preview | None — SQL delta calculation | Existing Drizzle ORM + TanStack Query |
|
||||
| Drag-to-reorder candidates | `Reorder` component | Already in `framer-motion@12.37.0` |
|
||||
| Pros/cons text fields | None — schema + form | Existing Drizzle + Zod + React |
|
||||
|
||||
**Why this over alternatives:**
|
||||
### How Each Feature Uses the Existing Stack
|
||||
|
||||
| Criterion | react-minimal-pie-chart | Recharts | Custom SVG | Chart.js |
|
||||
|-----------|------------------------|----------|------------|----------|
|
||||
| Bundle size | ~2kB gzipped | ~97kB gzipped | 0kB | ~60kB gzipped |
|
||||
| Chart types needed | Pie + donut (exactly what we need) | Overkill (line, bar, area, scatter, etc.) | Manual math | Overkill |
|
||||
| React 19 support | Explicit in peerDeps | Isolated rendering issues reported with 19.2.x | N/A | Wrapper has open React 19 issues |
|
||||
| Interactivity | Click, hover, focus, keyboard events per segment | Full but heavy | Must implement from scratch | Canvas-based (harder to style) |
|
||||
| Labels | Render prop for custom labels (percentage, value, SVG) | Built-in | Must implement | Built-in |
|
||||
| Animation | CSS-based, configurable duration/easing, reveal effect | D3-based, heavier | Must implement | Canvas animation |
|
||||
| Learning curve | Minimal -- one component, straightforward props | Moderate -- many components | High -- SVG arc math | Moderate |
|
||||
| Maintenance risk | Low -- tiny surface area, stable API | Low -- large community | Zero | Medium -- Canvas abstraction |
|
||||
#### 1. Side-by-Side Candidate Comparison
|
||||
|
||||
**Why not custom SVG:** The SVG `<circle>` + `stroke-dasharray` approach works for static charts but breaks interactivity (stacked circles mean only the last segment is clickable). The `<path>` arc approach gives full interactivity but requires implementing arc math, animation, labels, hover states, and accessibility from scratch. At ~2kB, react-minimal-pie-chart costs less than the custom code would and handles all edge cases.
|
||||
|
||||
**Why not Recharts:** GearBox needs exactly one chart type (donut/pie). Recharts adds ~97kB of unused capability. It also had isolated rendering issues reported with React 19.2.x, and pulls in D3 submodules. Significant overkill for this use case.
|
||||
|
||||
## Existing Stack Usage for Each Feature
|
||||
|
||||
### 1. Search/Filter Items
|
||||
|
||||
**No new dependencies.** Uses existing Drizzle ORM operators and React state.
|
||||
**No schema changes. No new dependencies.**
|
||||
|
||||
| Existing Tech | How It Is Used |
|
||||
|---------------|----------------|
|
||||
| Drizzle ORM `like()` | Server-side text search on `items.name` column. SQLite LIKE is case-insensitive by default, so no need for `ilike()`. |
|
||||
| Drizzle ORM `eq()`, `and()` | Category filter: `eq(items.categoryId, selectedId)`. Combine with search: `and(like(...), eq(...))`. |
|
||||
| TanStack Query | New query key pattern: `["items", { search, categoryId }]` for filtered results. Server-side filtering preferred over client-side to establish the pattern early (collections grow). |
|
||||
| Zustand or URL search params | Store active filter state. URL search params preferred (already used for tab state) so filter state is shareable/bookmarkable. |
|
||||
| Zod | Validate query params on the Hono route: `z.object({ search: z.string().optional(), categoryId: z.number().optional() })`. |
|
||||
| Tailwind CSS v4 | Responsive comparison table layout. Horizontal scroll on mobile with `overflow-x-auto`. Fixed first column (row labels) using `sticky left-0`. |
|
||||
| TanStack Query (`useThread`) | Thread detail already fetches all candidates in one query. Comparison view reads from the same cached data — no new API endpoint. |
|
||||
| Lucide React | Comparison row icons (weight, price, status, link). Already in the curated icon set. |
|
||||
| `formatWeight` / `formatPrice` | Existing formatters handle display with selected unit/currency. No changes needed. |
|
||||
| `useWeightUnit` / `useCurrency` | Existing hooks provide formatting context. Comparison view uses them identically to `CandidateCard`. |
|
||||
|
||||
**Implementation approach:** Add query parameters to `GET /api/items` rather than client-side filtering. Drizzle's conditional filter pattern handles optional params cleanly:
|
||||
**Implementation approach:** Add a view toggle (grid vs. comparison table) to the thread detail page. The comparison view is a `<table>` or CSS grid with candidates as columns and attributes as rows. Data already lives in `useThread` response — no API changes.
|
||||
|
||||
#### 2. Setup Impact Preview
|
||||
|
||||
**No new dependencies. Requires one new API endpoint.**
|
||||
|
||||
| Existing Tech | How It Is Used |
|
||||
|---------------|----------------|
|
||||
| Drizzle ORM | New query: for a given setup, sum `weight_grams` and `price_cents` of its items. Then compute delta against each candidate's `weight_grams` and `price_cents`. Pure arithmetic in the service layer. |
|
||||
| TanStack Query | New `useSetupImpact(threadId, setupId)` hook fetching `GET /api/threads/:threadId/impact?setupId=X`. Returns array of `{ candidateId, weightDelta, costDelta }`. |
|
||||
| Hono + Zod validator | New route validates `setupId` query param. Delegates to service function. |
|
||||
| `formatWeight` / `formatPrice` | Format deltas with `+` prefix for positive values (candidate adds weight/cost) and `-` for negative (candidate is lighter/cheaper than what's already in setup). |
|
||||
| `useSetups` hook | Existing `useSetups()` provides the setup list for the picker dropdown. |
|
||||
|
||||
**Delta calculation logic (server-side service):**
|
||||
|
||||
```typescript
|
||||
import { like, eq, and } from "drizzle-orm";
|
||||
// For each candidate in thread:
|
||||
// weightDelta = candidate.weightGrams - (matching item in setup).weightGrams
|
||||
// If no matching item in setup (it would be added, not replaced): delta = candidate.weightGrams
|
||||
|
||||
const conditions = [];
|
||||
if (search) conditions.push(like(items.name, `%${search}%`));
|
||||
if (categoryId) conditions.push(eq(items.categoryId, categoryId));
|
||||
|
||||
db.select().from(items).where(and(...conditions));
|
||||
// A "matching item" means: item in setup with same categoryId as the thread.
|
||||
// This is the intended semantic: "how does picking this candidate affect my setup?"
|
||||
```
|
||||
|
||||
### 2. Weight Classification (Base/Worn/Consumable)
|
||||
**Key decision:** Impact preview is read-only and derived. It does not mutate any data. It computes what *would* happen if the candidate were picked, without modifying the setup. The delta is displayed inline on each candidate card or in the comparison view.
|
||||
|
||||
**No new dependencies.** Schema change + UI state.
|
||||
#### 3. Drag-to-Reorder Candidate Ranking with Pros/Cons
|
||||
|
||||
**No new DnD library. Requires schema changes.**
|
||||
|
||||
| Existing Tech | How It Is Used |
|
||||
|---------------|----------------|
|
||||
| Drizzle ORM | Add `weightClass` column to `setup_items` table: `text("weight_class").notNull().default("base")`. Classification is per-setup-item, not per-item globally (a sleeping bag is "base" in a bikepacking setup but might be categorized differently elsewhere). |
|
||||
| Zod | Extend `syncSetupItemsSchema` to include classification: `z.enum(["base", "worn", "consumable"])`. |
|
||||
| drizzle-kit | Generate migration for the new column: `bun run db:generate`. |
|
||||
| SQL aggregates | Compute base/worn/consumable weight subtotals server-side, same pattern as existing category totals in `useTotals`. |
|
||||
| `framer-motion@12.37.0` `Reorder` | `Reorder.Group` wraps the candidate list. `Reorder.Item` wraps each candidate card. `onReorder` updates local order state. `onDragEnd` fires the persist mutation. |
|
||||
| Drizzle ORM | Two new columns on `thread_candidates`: `sortOrder integer` (default 0, lower = higher rank) and `pros text` / `cons text` (nullable). |
|
||||
| TanStack Query mutation | `usePatchCandidate` for pros/cons text updates. `useReorderCandidates` for bulk sort order update after drag-end. |
|
||||
| Hono + Zod validator | `PATCH /api/threads/:threadId/candidates/reorder` accepts `{ candidates: Array<{ id, sortOrder }> }`. `PATCH /api/candidates/:id` accepts `{ pros?, cons? }`. |
|
||||
| Zod | Extend `updateCandidateSchema` with `pros: z.string().nullable().optional()`, `cons: z.string().nullable().optional()`, `sortOrder: z.number().int().optional()`. |
|
||||
|
||||
**Key design decision:** Weight classification belongs on `setup_items` (the join table), not on `items` directly. An item's classification depends on context -- hiking poles are "worn" if you always use them, "base" if they pitch your tent. LighterPack follows this same model. This means the `syncSetupItemsSchema` changes from `{ itemIds: number[] }` to `{ items: Array<{ itemId: number, weightClass: "base" | "worn" | "consumable" }> }`.
|
||||
**Framer Motion `Reorder` API pattern:**
|
||||
|
||||
### 3. Weight Distribution Charts
|
||||
```typescript
|
||||
import { Reorder } from "framer-motion";
|
||||
|
||||
**One new dependency:** `react-minimal-pie-chart` (documented above).
|
||||
// State holds candidates sorted by sortOrder
|
||||
const [orderedCandidates, setOrderedCandidates] = useState(
|
||||
[...candidates].sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
);
|
||||
|
||||
| Existing Tech | How It Is Used |
|
||||
|---------------|----------------|
|
||||
| TanStack Query (`useTotals`) | Already returns per-category weight totals. Extend to also return per-weight-class totals for a given setup. |
|
||||
| Tailwind CSS | Style chart container, legend, responsive layout. Chart labels use Tailwind color tokens for consistency. |
|
||||
| Lucide React | Category icons in the chart legend, consistent with existing CategoryHeader component. |
|
||||
// onReorder fires continuously during drag — update local state only
|
||||
// onDragEnd fires once on drop — persist to DB
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={orderedCandidates}
|
||||
onReorder={setOrderedCandidates}
|
||||
>
|
||||
{orderedCandidates.map((candidate) => (
|
||||
<Reorder.Item
|
||||
key={candidate.id}
|
||||
value={candidate}
|
||||
onDragEnd={() => persistOrder(orderedCandidates)}
|
||||
>
|
||||
<CandidateCard ... />
|
||||
</Reorder.Item>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
```
|
||||
|
||||
**Chart data sources:**
|
||||
- **By category:** Already available from `GET /api/totals` response (`categories` array with `totalWeight` per category). No new endpoint needed.
|
||||
- **By weight classification:** New endpoint `GET /api/setups/:id/breakdown` returning `{ base: number, worn: number, consumable: number }` computed from the `weight_class` column on `setup_items`.
|
||||
**Schema changes required:**
|
||||
|
||||
### 4. Candidate Status Tracking
|
||||
| Table | Column | Type | Default | Purpose |
|
||||
|-------|--------|------|---------|---------|
|
||||
| `thread_candidates` | `sort_order` | `integer NOT NULL` | `0` | Rank position (lower = higher rank) |
|
||||
| `thread_candidates` | `pros` | `text` | `NULL` | Free-text pros annotation |
|
||||
| `thread_candidates` | `cons` | `text` | `NULL` | Free-text cons annotation |
|
||||
|
||||
**No new dependencies.** Schema change + UI update.
|
||||
|
||||
| Existing Tech | How It Is Used |
|
||||
|---------------|----------------|
|
||||
| Drizzle ORM | Add `status` column to `thread_candidates` table: `text("status").notNull().default("researching")`. Values: `"researching"`, `"ordered"`, `"arrived"`. |
|
||||
| Zod | Add to `createCandidateSchema` and `updateCandidateSchema`: `status: z.enum(["researching", "ordered", "arrived"]).default("researching")`. |
|
||||
| Tailwind CSS | Status badge colors on CandidateCard (gray for researching, amber for ordered, green for arrived). Same badge pattern used for thread status already. |
|
||||
| Lucide React | Status icons: `search` for researching, `truck` for ordered, `check-circle` for arrived. Already in the curated icon set. |
|
||||
|
||||
### 5. Weight Unit Selection
|
||||
|
||||
**No new dependencies.** Settings storage + formatter change.
|
||||
|
||||
| Existing Tech | How It Is Used |
|
||||
|---------------|----------------|
|
||||
| SQLite `settings` table | Store preferred unit: `{ key: "weightUnit", value: "g" }`. Same pattern as existing onboarding settings. |
|
||||
| React Query (`useSettings`) | Already exists. Fetch and cache the weight unit preference. |
|
||||
| `formatWeight()` in `lib/formatters.ts` | Extend to accept a unit parameter and convert from grams (the canonical storage format). |
|
||||
| Zustand (optional) | Could cache the unit preference in UI store for synchronous access in formatters. Alternatively, pass it through React context or as a parameter. |
|
||||
|
||||
**Conversion constants (stored weights are always grams):**
|
||||
|
||||
| Unit | From Grams | Display Format |
|
||||
|------|-----------|----------------|
|
||||
| g (grams) | `x` | `${Math.round(x)}g` |
|
||||
| oz (ounces) | `x / 28.3495` | `${(x / 28.3495).toFixed(1)}oz` |
|
||||
| lb (pounds) | `x / 453.592` | `${(x / 453.592).toFixed(2)}lb` |
|
||||
| kg (kilograms) | `x / 1000` | `${(x / 1000).toFixed(2)}kg` |
|
||||
|
||||
**Key decision:** Store weights in grams always. Convert on display only. This avoids precision loss from repeated conversions and keeps the database canonical. The `formatWeight` function becomes the single conversion point.
|
||||
**Sort order persistence pattern:** On drag-end, send the full reordered array with new `sortOrder` values (0-based index positions). Backend replaces existing `sort_order` values atomically. This is the same delete-all + re-insert pattern used for `setupItems` but as an UPDATE instead.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Only new dependency for v1.2
|
||||
bun add react-minimal-pie-chart
|
||||
# No new packages. Zero.
|
||||
```
|
||||
|
||||
That is it. One package, under 2kB gzipped.
|
||||
All required capabilities are already installed.
|
||||
|
||||
## Schema Changes Summary
|
||||
## Alternatives Considered
|
||||
|
||||
These are the Drizzle schema modifications needed (no new tables, just column additions):
|
||||
### Drag and Drop: Why Not Add a Dedicated Library?
|
||||
|
||||
| Table | Change | Migration |
|
||||
|-------|--------|-----------|
|
||||
| `setup_items` | Add `weightClass: text("weight_class").notNull().default("base")` | `bun run db:generate && bun run db:push` |
|
||||
| `thread_candidates` | Add `status: text("status").notNull().default("researching")` | `bun run db:generate && bun run db:push` |
|
||||
| `settings` | No schema change (already key-value). Insert `weightUnit` row. | Seed via service or onboarding. |
|
||||
| Option | Version | React 19 Status | Verdict |
|
||||
|--------|---------|-----------------|---------|
|
||||
| `framer-motion` Reorder (already installed) | 12.37.0 | React 19 explicit peerDep (`^18.0.0 || ^19.0.0`) | USE THIS |
|
||||
| `@dnd-kit/core` + `@dnd-kit/sortable` | 6.3.1 | No React 19 support (stale ~1yr, open GitHub issue #1511) | AVOID |
|
||||
| `@dnd-kit/react` (new rewrite) | 0.3.2 | React 19 compatible | Pre-1.0, no maintainer ETA on stable |
|
||||
| `@hello-pangea/dnd` | 18.0.1 | No React 19 (stale ~1yr, peerDep `^18.0.0` only) | AVOID |
|
||||
| `pragmatic-drag-and-drop` | latest | Core is React-agnostic but some sub-packages missing React 19 | Overkill for a single sortable list |
|
||||
| Custom HTML5 DnD | N/A | N/A | 200+ lines of boilerplate, worse accessibility |
|
||||
|
||||
**Why framer-motion `Reorder` wins:** Already in the bundle. React 19 peer dep confirmed in lockfile. Handles the single use case (vertical sortable list) with 10 lines of code. Provides smooth layout animations at zero additional cost. The limitation (no cross-container drag, no multi-row grid) does not apply — candidate ranking is a single vertical list.
|
||||
|
||||
**Why not `@dnd-kit`:** The legacy `@dnd-kit/core@6.3.1` has no official React 19 support and has been unmaintained for ~1 year. The new `@dnd-kit/react@0.3.2` does support React 19 but is pre-1.0 with zero maintainer response on stability/roadmap questions (GitHub Discussion #1842 has 0 replies). Adding a pre-1.0 library when the project already has a working solution is unjustifiable.
|
||||
|
||||
### Setup Impact: Why Not Client-Side Calculation?
|
||||
|
||||
Client-side delta calculation (using cached React Query data) is simpler to implement but:
|
||||
- Requires loading both the full setup items list AND all candidates into the client
|
||||
- Introduces staleness bugs if setup items change in another tab
|
||||
- Is harder to test (service test vs. component test)
|
||||
|
||||
Server-side calculation in a service function is testable, authoritative, and consistent with the existing architecture (services compute aggregates, not components).
|
||||
|
||||
## What NOT to Add
|
||||
|
||||
| Avoid | Why | Use Instead |
|
||||
|-------|-----|-------------|
|
||||
| Recharts | 97kB for one chart type. React 19 edge-case issues. D3 dependency chain. | react-minimal-pie-chart (2kB) |
|
||||
| Chart.js / react-chartjs-2 | Canvas-based (harder to style with Tailwind). Open React 19 peer dep issues. Overkill. | react-minimal-pie-chart |
|
||||
| visx | Low-level D3 primitives. Steep learning curve. Have to build chart from scratch. Great for custom viz, overkill for a donut chart. | react-minimal-pie-chart |
|
||||
| Fuse.js or similar search library | Client-side fuzzy search adds bundle weight and complexity. SQLite LIKE is sufficient for name search on a single-user collection (hundreds of items, not millions). | Drizzle `like()` operator |
|
||||
| Full-text search (FTS5) | SQLite FTS5 is powerful but requires virtual tables and different query syntax. Overkill for simple name matching on small collections. | Drizzle `like()` operator |
|
||||
| i18n library for unit conversion | This is not internationalization. It is four conversion constants and a formatter function. A library would be absurd. | Custom `formatWeight()` function |
|
||||
| State machine library (XState) | Candidate status is a simple enum, not a complex state machine. Three values with no guards or side effects. | Zod enum + Drizzle text column |
|
||||
| New Zustand store for filters | Filter state should live in URL search params for shareability/bookmarkability. The collection page already uses this pattern for tabs. | TanStack Router search params |
|
||||
| `@dnd-kit/core` + `@dnd-kit/sortable` | No React 19 support, stale for ~1 year (latest 6.3.1 from 2024) | `framer-motion` Reorder (already installed) |
|
||||
| `@hello-pangea/dnd` | No React 19 support, peerDep `react: "^18.0.0"` only, stale | `framer-motion` Reorder |
|
||||
| `react-comparison-table` or similar component packages | Fragile third-party layouts for a simple table. Custom Tailwind table is trivial and design-consistent. | Custom Tailwind CSS table layout |
|
||||
| Modal/dialog library (Radix, Headless UI) | The project already has a hand-rolled modal pattern (`SlideOutPanel`, `ConfirmDialog`). Adding a library for one more dialog adds inconsistency. | Extend existing modal patterns |
|
||||
| Rich text editor for pros/cons | Markdown editors are overkill for a single-line annotation field. Users want a quick note, not a document. | Plain `<textarea>` with Tailwind styling |
|
||||
|
||||
## Existing Stack Version Compatibility
|
||||
## Stack Patterns by Variant
|
||||
|
||||
All existing dependencies remain unchanged. The only version consideration:
|
||||
**If the comparison view needs mobile scroll:**
|
||||
- Wrap comparison table in `overflow-x-auto`
|
||||
- Freeze the first column (attribute labels) with `sticky left-0 bg-white z-10`
|
||||
- This is pure CSS, no JavaScript or library needed
|
||||
|
||||
| New Package | Compatible With | Verified |
|
||||
|-------------|-----------------|----------|
|
||||
| react-minimal-pie-chart ^9.1.2 | React 19 (`peerDeps: "^16.8.0 \|\| ^17 \|\| ^18 \|\| ^19"`) | YES -- package.json on GitHub confirms. Dev deps test against React 19.0.0. |
|
||||
| react-minimal-pie-chart ^9.1.2 | TypeScript 5.x | YES -- library is TypeScript native (built with TS 3.8+). |
|
||||
| react-minimal-pie-chart ^9.1.2 | Bun bundler / Vite | YES -- pure ESM, no native dependencies, standard npm package. |
|
||||
**If the setup impact preview needs a setup picker:**
|
||||
- Use `useSetups()` (already exists) to populate a `<select>` dropdown
|
||||
- Store selected setup ID in local component state (not URL params — this is transient UI)
|
||||
- No new state management needed
|
||||
|
||||
**If pros/cons fields need to auto-save:**
|
||||
- Use a debounced mutation (300-500ms) that fires on `onChange`
|
||||
- Or save on `onBlur` (simpler, adequate for this use case)
|
||||
- Existing `useUpdateCandidate` hook already handles candidate mutations — extend schema only
|
||||
|
||||
## Version Compatibility
|
||||
|
||||
| Package | Version in Project | React 19 Compatible | Notes |
|
||||
|---------|-------------------|---------------------|-------|
|
||||
| `framer-motion` | 12.37.0 | YES — peerDeps `"^18.0.0 || ^19.0.0"` confirmed in lockfile | `Reorder` component available since v5 |
|
||||
| `drizzle-orm` | 0.45.1 | N/A (server-side) | ALTER TABLE or migration for new columns |
|
||||
| `zod` | 4.3.6 | N/A | Extend existing schemas |
|
||||
| `@tanstack/react-query` | 5.90.21 | YES | New hooks follow existing patterns |
|
||||
|
||||
## Sources
|
||||
|
||||
- [Drizzle ORM Filter Operators](https://orm.drizzle.team/docs/operators) -- `like`, `eq`, `and`, `or` operators for search/filter (HIGH confidence)
|
||||
- [Drizzle ORM Conditional Filters Guide](https://orm.drizzle.team/docs/guides/conditional-filters-in-query) -- dynamic filter composition pattern (HIGH confidence)
|
||||
- [react-minimal-pie-chart GitHub](https://github.com/toomuchdesign/react-minimal-pie-chart) -- version 9.1.2, React 19 peerDeps confirmed (HIGH confidence)
|
||||
- [react-minimal-pie-chart package.json](https://github.com/toomuchdesign/react-minimal-pie-chart/blob/master/package.json) -- React 19 in peerDependencies and devDependencies (HIGH confidence)
|
||||
- [Recharts npm](https://www.npmjs.com/package/recharts) -- v3.8.0, ~97kB bundle (HIGH confidence)
|
||||
- [Recharts React 19 issue #6857](https://github.com/recharts/recharts/issues/6857) -- rendering issues reported with React 19.2.3 (MEDIUM confidence -- may be project-specific)
|
||||
- [LighterPack weight classification model](https://lighterpack.com) -- base/worn/consumable terminology is industry standard for gear management (HIGH confidence)
|
||||
- [Pack Weight Calculator Guide](https://backpackpeek.com/blog/pack-weight-calculator-base-weight-guide) -- weight classification definitions (HIGH confidence)
|
||||
- [SQLite LIKE case sensitivity note](https://github.com/drizzle-team/drizzle-orm-docs/issues/239) -- LIKE is case-insensitive in SQLite, no need for ilike (MEDIUM confidence)
|
||||
- [framer-motion package lockfile entry] — peerDeps `react: "^18.0.0 || ^19.0.0"` confirmed (HIGH confidence, from project's `bun.lock`)
|
||||
- [Motion Reorder docs](https://motion.dev/docs/react-reorder) — `Reorder.Group`, `Reorder.Item`, `useDragControls` API, `onDragEnd` pattern for persisting order (HIGH confidence)
|
||||
- [Motion Changelog](https://motion.dev/changelog) — v12.37.0 actively maintained through Feb 2026 (HIGH confidence)
|
||||
- [@dnd-kit/core npm](https://www.npmjs.com/package/@dnd-kit/core) — v6.3.1, last published ~1 year ago, no React 19 support (HIGH confidence)
|
||||
- [dnd-kit React 19 issue #1511](https://github.com/clauderic/dnd-kit/issues/1511) — CLOSED but React 19 TypeScript issues confirmed (MEDIUM confidence)
|
||||
- [@dnd-kit/react roadmap discussion #1842](https://github.com/clauderic/dnd-kit/discussions/1842) — 0 maintainer replies on stability question (HIGH confidence — signals pre-1.0 risk)
|
||||
- [hello-pangea/dnd React 19 issue #864](https://github.com/hello-pangea/dnd/issues/864) — React 19 support still open as of Jan 2026 (HIGH confidence)
|
||||
- [Top 5 Drag-and-Drop Libraries for React 2026](https://puckeditor.com/blog/top-5-drag-and-drop-libraries-for-react) — ecosystem overview confirming dnd-kit and hello-pangea/dnd limitations (MEDIUM confidence)
|
||||
|
||||
---
|
||||
*Stack research for: GearBox v1.2 -- Collection Power-Ups*
|
||||
*Stack research for: GearBox v1.3 -- Research & Decision Tools*
|
||||
*Researched: 2026-03-16*
|
||||
|
||||
@@ -1,207 +1,181 @@
|
||||
# Project Research Summary
|
||||
|
||||
**Project:** GearBox v1.2 -- Collection Power-Ups
|
||||
**Domain:** Gear management (bikepacking, sim racing, etc.) -- feature enhancement milestone
|
||||
**Project:** GearBox v1.3 — Research & Decision Tools
|
||||
**Domain:** Gear management — candidate comparison, setup impact preview, drag-to-reorder ranking with pros/cons
|
||||
**Researched:** 2026-03-16
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Executive Summary
|
||||
|
||||
GearBox v1.2 adds six features to the existing gear management app: item search/filter, weight classification (base/worn/consumable), weight distribution charts, candidate status tracking, weight unit selection, and a planning category filter upgrade. Research confirms that four of six features require zero new dependencies -- they are pure application logic built on the existing stack (Drizzle ORM, React Query, Zod, Tailwind). The sole new dependency is `react-minimal-pie-chart` (~2kB gzipped) for donut chart visualization. The codebase is well-positioned for these additions: the settings table already supports key-value preferences, the `setup_items` join table is the correct place for weight classification, and the client-side data model is small enough for in-memory filtering.
|
||||
GearBox v1.3 adds three decision-support features to the existing thread detail page: side-by-side candidate comparison, setup impact preview (weight/cost delta), and drag-to-reorder candidate ranking with pros/cons annotation. All four research areas converge on the same conclusion — the existing stack is sufficient and no new dependencies are required. `framer-motion@12.37.0` (already installed) provides the `Reorder` component for drag-to-reorder, eliminating the need for `@dnd-kit` (which lacks React 19 support) or any other library. Two of the three features (comparison view and impact preview) require zero schema changes and can be built as pure client-side derived views using data already cached by `useThread()` and `useSetup()`.
|
||||
|
||||
The recommended approach is to build weight unit selection first because it refactors the `formatWeight` function that every subsequent feature depends on for display. Search/filter and candidate status tracking are independent and low-risk. Weight classification is the most architecturally significant change -- it adds a column to the `setup_items` join table and changes the sync API shape from `{ itemIds: number[] }` to `{ items: Array<{ itemId, weightClass }> }`. Weight distribution charts come last because they depend on both the unit formatter and the classification data. The two schema changes (columns on `setup_items` and `thread_candidates`) should be batched into a single Drizzle migration.
|
||||
The recommended build sequence is dependency-driven: schema migration first (adds `sort_order`, `pros`, `cons` to `thread_candidates`), then ranking UI (uses the new columns), then comparison view and impact preview in parallel (both are schema-independent client additions). This order eliminates the risk of mid-feature migrations and ensures the comparison table can display rank, pros, and cons from day one rather than being retrofitted. The entire milestone touches 3 new files and 10 modified files — a contained, low-blast-radius changeset.
|
||||
|
||||
The primary risks are: (1) weight unit conversion rounding drift from bidirectional conversion in edit forms, (2) accidentally placing weight classification on the `items` table instead of the `setup_items` join table, and (3) chart data diverging from displayed totals due to separate computation paths. All three are preventable with clear architectural rules established in the first phase: store grams canonically, convert only at the display boundary, and use a single source of truth for weight computations.
|
||||
The primary risks are implementation-level rather than architectural. Three patterns require deliberate design before coding: (1) use `tempItems` local state alongside React Query for drag reorder to prevent the well-documented flicker bug, (2) use `sortOrder REAL` (fractional) instead of `INTEGER` to avoid bulk UPDATE writes on every drag, and (3) treat impact preview as an "add vs replace" decision — not just a pure addition — since users comparing gear are almost always replacing an existing item, not stacking one on top. All three are avoidable with upfront design; recovery cost is low but retrofitting is disruptive.
|
||||
|
||||
## Key Findings
|
||||
|
||||
### Recommended Stack
|
||||
|
||||
The existing stack (React 19, Hono, Drizzle ORM, SQLite, Bun) handles all v1.2 features without modification. One small library addition is needed.
|
||||
Zero new dependencies are needed for this milestone. The existing stack handles all three features: Tailwind CSS for the comparison table layout, `framer-motion`'s `Reorder` component for drag ordering, Drizzle ORM + Hono + Zod for the one new write endpoint (`PATCH /api/threads/:id/candidates/reorder`), and TanStack Query for the new `useReorderCandidates` mutation. All other React Query hooks (`useThread`, `useSetup`, `useSetups`) already exist and return the data needed for comparison and impact preview without modification.
|
||||
|
||||
**Core technologies (all existing, no changes):**
|
||||
- **Drizzle ORM `like()`, `eq()`, `and()`**: Available for server-side filtering if needed in the future, but client-side filtering is preferred at this scale
|
||||
- **Zod `z.enum()`**: Validates weight classification (`"base" | "worn" | "consumable"`) and candidate status (`"researching" | "ordered" | "arrived"`) with compile-time type safety
|
||||
- **React Query `useSetting()`**: Reactive settings caching ensures unit preference changes propagate to all weight displays without page refresh
|
||||
- **Existing `settings` table**: Key-value store supports weight unit preference with no schema change
|
||||
**Core technologies:**
|
||||
- `framer-motion@12.37.0` (Reorder component): drag-to-reorder — already installed, React 19 peerDeps confirmed in `bun.lock`, replaces any need for `@dnd-kit`
|
||||
- `drizzle-orm@0.45.1`: three new columns on `thread_candidates` (`sort_order REAL`, `pros TEXT`, `cons TEXT`) plus one new service function (`reorderCandidates`)
|
||||
- Tailwind CSS v4: comparison table layout with `overflow-x-auto`, `sticky left-0` for frozen label column, `min-w-[200px]` per candidate column
|
||||
- TanStack Query v5 + existing hooks: impact preview and comparison view derived entirely from cached `useThread` + `useSetup` data — no new API endpoints on read paths
|
||||
- Zod v4: extend `updateCandidateSchema` with `sortOrder: z.number().finite()`, `pros: z.string().max(500).optional()`, `cons: z.string().max(500).optional()`
|
||||
|
||||
**New dependency:**
|
||||
- **react-minimal-pie-chart ^9.1.2**: Donut/pie charts at ~2kB gzipped. React 19 compatible (explicit in peerDeps). Zero external dependencies. TypeScript native. Chosen over Recharts (~97kB, React 19 rendering issues reported) and Chart.js (~60kB, canvas-based, harder to style with Tailwind).
|
||||
|
||||
**What NOT to add:**
|
||||
- Recharts, Chart.js, or visx (massive overkill for one chart type)
|
||||
- Fuse.js or FTS5 (overkill for name search on sub-1000 item collections)
|
||||
- XState (candidate status is a simple enum, not a complex state machine)
|
||||
- i18n library for unit conversion (four constants and a formatter function)
|
||||
**What NOT to use:**
|
||||
- `@dnd-kit/core@6.3.1` — no React 19 support, unmaintained for ~1 year
|
||||
- `@dnd-kit/react@0.3.2` — pre-1.0, no maintainer response on stability
|
||||
- `@hello-pangea/dnd@18.0.1` — `peerDep react: "^18.0.0"` only, stale
|
||||
- Any third-party comparison table component — custom Tailwind table is trivial and design-consistent
|
||||
|
||||
### Expected Features
|
||||
|
||||
All five v1.3 features are confirmed as P1 (must-have for this milestone). No existing gear management tool (LighterPack, GearGrams, OutPack) has comparison view, delta preview, or ranking — these are unmet-need differentiators adapted from e-commerce comparison UX to the gear domain.
|
||||
|
||||
**Must have (table stakes):**
|
||||
- **Search items by name** -- every competitor with an inventory has search; LighterPack notably lacks it and users complain
|
||||
- **Filter items by category** -- partially exists in planning view, missing from collection view
|
||||
- **Weight unit selection (g/oz/lb/kg)** -- universal across all competitors; gear specs come in mixed units
|
||||
- **Weight classification (base/worn/consumable)** -- pioneered by LighterPack, now industry standard; "base weight" is the core metric of the ultralight community
|
||||
- Side-by-side comparison view — users juggling 3+ candidates mentally across cards expect tabular layout; NNGroup and Smashing Magazine confirm this is the standard for comparison contexts
|
||||
- Weight and cost delta per candidate — gear apps always display weight prominently; delta is more actionable than raw weight
|
||||
- Setup selector for impact preview — required to contextualize the delta; `useSetups()` already exists
|
||||
|
||||
**Should have (differentiators):**
|
||||
- **Weight distribution donut chart** -- LighterPack's pie chart is cited as its best feature; GearBox can uniquely combine category and classification breakdown
|
||||
- **Candidate status tracking (researching/ordered/arrived)** -- entirely unique to GearBox's planning thread concept; no competitor has purchase lifecycle tracking
|
||||
- **Per-setup classification** -- architecturally superior to competitors; the same item can be classified differently across setups
|
||||
- Drag-to-rank ordering — makes priority explicit without numeric input; no competitor has this in the gear domain; requires `sort_order` schema migration
|
||||
- Per-candidate pros/cons fields — structured decision rationale; stored as newline-delimited text (renders as bullets in comparison view); requires `pros`/`cons` schema migration
|
||||
|
||||
**Defer (v2+):**
|
||||
- Per-item weight input in multiple units (parsing complexity)
|
||||
- Interactive chart drill-down (click to zoom into categories)
|
||||
- Weight goals/targets (opinionated norms conflict with hobby-agnostic design)
|
||||
- Custom weight classification labels beyond base/worn/consumable
|
||||
- Server-side full-text search (premature for single-user scale)
|
||||
- Status change timestamps on candidates (useful but not essential now)
|
||||
- Classification-aware impact breakdown (base/worn/consumable) — data available but UI complexity high; flat delta covers 90% of use case
|
||||
- Rank badge on card grid — useful but low urgency; add when users express confusion
|
||||
- Mobile-optimized comparison view (swipe between candidates) — horizontal scroll works for now
|
||||
- Comparison permalink — requires auth/multi-user work not in scope for v1
|
||||
|
||||
**Anti-features (explicitly rejected):**
|
||||
- Custom comparison attributes — complexity trap, rejected in PROJECT.md
|
||||
- Score/rating calculation — opaque algorithms distrust; manual ranking expresses user preference better
|
||||
- Cross-thread comparison — candidates are decision-scoped; different categories are not apples-to-apples
|
||||
|
||||
### Architecture Approach
|
||||
|
||||
All v1.2 features integrate into the existing three-layer architecture (client/server/database) with minimal structural changes. The client layer gains 5 new files (SearchBar, WeightChart, UnitSelector components; useFormatWeight hook; migration SQL) and modifies 15 existing files. The server layer changes are limited to the setup service (weight classification PATCH endpoint, updated sync function) and thread service (candidate status field passthrough). No new route registrations are needed in `src/server/index.ts`. The API layer (`lib/api.ts`) and UI state store (`uiStore.ts`) require no changes.
|
||||
All three features integrate on the `/threads/$threadId` route with no impact on other routes. The comparison view and impact preview are pure client-side derived views using data already in the React Query cache — no new API endpoints on read paths. The only new server-side endpoint is `PATCH /:id/candidates/reorder` which accepts `{ orderedIds: number[] }` and applies a transactional bulk-update in `thread.service.ts`. The `uiStore` (Zustand) gains two new fields: `compareMode: boolean` and `impactSetupId: number | null`, consistent with existing UI-state-only patterns.
|
||||
|
||||
**Major components:**
|
||||
1. **`useFormatWeight` hook** -- single source of truth for unit-aware weight formatting; wraps `useSetting("weightUnit")` and `formatWeight(grams, unit)` so all weight displays stay consistent
|
||||
2. **`WeightChart` component** -- reusable donut chart wrapper; used in collection page (weight by category) and setup detail page (weight by classification)
|
||||
3. **`SearchBar` component** -- reusable search input with clear button; collection page filters via `useMemo` over the cached `useItems()` data
|
||||
4. **Updated `syncSetupItems`** -- breaking API change from `{ itemIds: number[] }` to `{ items: Array<{ itemId, weightClass }> }`; single call site (ItemPicker.tsx) makes this safe
|
||||
5. **`PATCH /api/setups/:id/items/:itemId`** -- new endpoint for updating weight classification without triggering full sync (which would destroy classification data)
|
||||
1. `CandidateCompare.tsx` (new) — side-by-side table; columns = candidates, rows = attributes; pure presentational, derives deltas from `thread.candidates[]`; `overflow-x-auto` for narrow viewports; sticky label column
|
||||
2. `SetupImpactRow.tsx` (new) — delta display (`+Xg / +$Y`); reads from `useSetup(impactSetupId)` data passed as props; handles null weight case explicitly
|
||||
3. `Reorder.Group` / `Reorder.Item` (framer-motion, no new file) — wraps `CandidateCard` list in `$threadId.tsx`; `onReorder` updates local `orderedCandidates` state; `onDragEnd` fires `useReorderCandidates` mutation
|
||||
4. `CandidateCard.tsx` (modified) — gains `rank` prop (gold/silver/bronze badge for top 3), pros/cons indicator icons; `isActive={false}` when rendered inside comparison view
|
||||
5. `CandidateForm.tsx` (modified) — gains `pros`/`cons` textarea fields below existing Notes field
|
||||
|
||||
**Key patterns to follow:**
|
||||
- `tempItems` local state alongside React Query for drag reorder — prevents the documented flicker bug; do not use `setQueryData` alone
|
||||
- Client-computed derived data from cached queries — no new read endpoints (anti-pattern: building `GET /api/threads/:id/compare` or `GET /api/threads/:id/impact`)
|
||||
- `uiStore` for cross-panel persistent UI flags only — no server data in Zustand
|
||||
- Resolved-thread guard — `thread.status === "resolved"` must disable drag handles and block the reorder endpoint (data integrity requirement, not just UX)
|
||||
|
||||
### Critical Pitfalls
|
||||
|
||||
1. **Weight unit conversion rounding drift** -- bidirectional conversion in edit forms causes grams to drift over multiple edit cycles. Always load stored grams from the API, convert for display, and convert user input back to grams once on save. Never re-convert from a previously displayed value.
|
||||
1. **Drag flicker from `setQueryData`-only optimistic update** — use `tempItems` local state (`useState<Candidate[] | null>(null)`); render from `tempItems ?? queryData.candidates`; clear on mutation `onSettled`. Must be designed before building the drag UI, not retrofitted. (PITFALLS.md Pitfall 1)
|
||||
|
||||
2. **Weight classification at the wrong level** -- placing `classification` on the `items` table instead of `setup_items` prevents per-setup classification. A rain jacket is "worn" in summer but "base weight" in winter. This is the single most important schema decision in v1.2 and is costly to reverse.
|
||||
2. **Integer `sortOrder` causes bulk writes** — use `REAL` (float) type for `sort_order` column with fractional indexing so only the moved item requires a single UPDATE. With 8+ candidates and rapid dragging, integer bulk updates produce visible latency and hold a SQLite write lock. Start values at 1000 with 1000-unit gaps. (PITFALLS.md Pitfall 2)
|
||||
|
||||
3. **Chart data diverging from displayed totals** -- the codebase already has two computation paths (SQL aggregates in `totals.service.ts` vs. JavaScript reduce in `$setupId.tsx`). Adding charts creates a third. Use a shared utility for weight summation and convert units only at the final display step.
|
||||
3. **Impact preview shows wrong delta (add vs replace)** — default to "replace" mode when a setup item exists in the same category as the thread; default to "add" mode when no category match. Pure-addition delta misleads users: a 500g candidate replacing an 800g item shows "+500g" instead of "-300g". The distinction must be designed into the service layer, not retrofitted. (PITFALLS.md Pitfall 6)
|
||||
|
||||
4. **Server-side search for client-side data** -- adding search API parameters creates React Query cache fragmentation and unnecessary latency. Keep filtering client-side with `useMemo` over the cached items array.
|
||||
4. **Comparison/rank on resolved threads** — `thread.status === "resolved"` must hide drag handles, disable rank mutation, and show a read-only summary. The reorder API route must return 400 for resolved threads. This is a data integrity issue, not just UX. (PITFALLS.md Pitfall 8)
|
||||
|
||||
5. **Test helper desync with schema** -- the manual `createTestDb()` in `tests/helpers/db.ts` duplicates schema in raw SQL. Every column addition must be mirrored there or tests pass against the wrong schema.
|
||||
5. **Test helper schema drift** — every schema change must update `tests/helpers/db.ts` in the same commit. Run `bun test` immediately after schema + helper update. Missing this produces `SqliteError: no such column` failures. (PITFALLS.md Pitfall 7)
|
||||
|
||||
## Implications for Roadmap
|
||||
|
||||
Based on combined research, a 5-phase structure is recommended:
|
||||
Based on research, a 4-phase structure is recommended with a clear dependency order: schema foundation first, ranking second (consumes new columns), then comparison view and impact preview as sequential client-only phases.
|
||||
|
||||
### Phase 1: Weight Unit Selection
|
||||
### Phase 1: Schema Foundation + Pros/Cons Fields
|
||||
|
||||
**Rationale:** Foundational infrastructure. The `formatWeight` refactor touches every component that displays weight (~8 call sites). All subsequent features depend on this formatter working correctly with unit awareness. Building this first means classification totals, chart labels, and setup breakdowns automatically display in the user's preferred unit.
|
||||
**Rationale:** All ranking and pros/cons work shares a schema migration. Batching `sort_order`, `pros`, and `cons` into a single migration avoids multiple ALTER TABLE runs and ensures the test helper is updated once. Pros/cons field UI is low-complexity (two textareas in `CandidateForm`) and can be delivered immediately after the migration, making candidates richer before ranking is built.
|
||||
**Delivers:** `sort_order REAL NOT NULL DEFAULT 0`, `pros TEXT`, `cons TEXT` on `thread_candidates`; pros/cons visible in candidate edit panel; `CandidateCard` shows pros/cons indicator icons; `tests/helpers/db.ts` updated; Zod schemas extended with 500-char length caps
|
||||
**Addresses:** Side-by-side comparison row data (pros/cons), drag-to-rank prerequisite (sort_order)
|
||||
**Avoids:** Test helper schema drift (Pitfall 7), pros/cons as unstructured blobs (Pitfall 5 — newline-delimited format chosen at schema time)
|
||||
|
||||
**Delivers:** Global weight unit preference (g/oz/lb/kg) stored in settings, `useFormatWeight` hook, updated `formatWeight` function, UnitSelector component in TotalsBar, correct unit display across all existing weight surfaces (ItemCard, CandidateCard, CategoryHeader, TotalsBar, setup detail), correct unit handling in ItemForm and CandidateForm weight inputs.
|
||||
### Phase 2: Drag-to-Reorder Candidate Ranking
|
||||
|
||||
**Addresses:** Weight unit selection (table stakes from FEATURES.md)
|
||||
**Rationale:** Depends on Phase 1 (`sort_order` column must exist). Schema work is done; this phase is pure service + client. The `tempItems` pattern must be implemented correctly from the start to prevent the React Query flicker bug.
|
||||
**Delivers:** `reorderCandidates` service function (transactional loop); `PATCH /api/threads/:id/candidates/reorder` endpoint with thread ownership validation; `useReorderCandidates` mutation hook; `Reorder.Group` / `Reorder.Item` in thread detail route; rank badge (gold/silver/bronze) on `CandidateCard`; resolved-thread guard (no drag handles, API returns 400 for resolved)
|
||||
**Uses:** `framer-motion@12.37.0` Reorder API (already installed), Drizzle ORM transaction, fractional `sort_order REAL` arithmetic (single UPDATE per drag)
|
||||
**Avoids:** dnd-kit flicker (Pitfall 1 — `tempItems` pattern), bulk integer writes (Pitfall 2 — REAL type), resolved-thread corruption (Pitfall 8)
|
||||
|
||||
**Avoids:** Rounding drift (Pitfall 1), inconsistent unit application (Pitfall 7), flash of unconverted weights on load
|
||||
### Phase 3: Side-by-Side Comparison View
|
||||
|
||||
**Schema changes:** None (uses existing settings table key-value store)
|
||||
**Rationale:** No schema dependency — can technically be built before Phase 2, but is most useful when rank, pros, and cons are already in the data model so the comparison table shows the full picture from day one. Pure client-side presentational component; no API changes.
|
||||
**Delivers:** `CandidateCompare.tsx` component; "Compare" toggle button in thread header; `compareMode` in `uiStore`; comparison table with sticky label column, horizontal scroll, weight/price relative deltas (lightest/cheapest candidate highlighted); responsive at 768px viewport; read-only summary for resolved threads
|
||||
**Implements:** Client-computed derived data pattern — data from `useThread()` cache; `Math.min` across candidates for relative delta; `formatWeight`/`formatPrice` for display
|
||||
**Avoids:** Comparison breaking at narrow widths (Pitfall 4 — `overflow-x-auto` + `min-w-[200px]`), comparison visible on resolved threads (Pitfall 8), server endpoint for comparison deltas (architecture anti-pattern)
|
||||
|
||||
### Phase 2: Search, Filter, and Planning Category Filter
|
||||
### Phase 4: Setup Impact Preview
|
||||
|
||||
**Rationale:** Pure client-side addition with no schema changes, no API changes, and no dependencies on other v1.2 features. Immediately useful as collections grow. The planning category filter upgrade fits naturally here since both involve filter UX and the icon-aware dropdown is a shared component.
|
||||
|
||||
**Delivers:** Search input in collection view, icon-aware category filter dropdown (reused in gear and planning tabs), filtered item display with count ("showing 12 of 47 items"), URL search param persistence, empty state for no results, result count display.
|
||||
|
||||
**Addresses:** Search items by name (table stakes), filter by category (table stakes), planning category filter upgrade (differentiator)
|
||||
|
||||
**Avoids:** Server-side search anti-pattern (Pitfall 3), search state lost on tab switch (UX pitfall), category groups disappearing incorrectly during filtering
|
||||
|
||||
**Schema changes:** None
|
||||
|
||||
### Phase 3: Candidate Status Tracking
|
||||
|
||||
**Rationale:** Simple schema change on `thread_candidates` with minimal integration surface. Independent of other features. Low complexity but requires awareness of the existing thread resolution flow. Schema change should be batched with Phase 4 into one Drizzle migration.
|
||||
|
||||
**Delivers:** Status column on candidates (researching/ordered/arrived), status badge on CandidateCard with click-to-cycle, status field in CandidateForm, Zod enum validation, status transition validation in service layer (researching -> ordered -> arrived, no backward transitions).
|
||||
|
||||
**Addresses:** Candidate status tracking (differentiator -- unique to GearBox)
|
||||
|
||||
**Avoids:** Status without transition validation (Pitfall 4), test helper desync (Pitfall 6), not handling candidate status during thread resolution
|
||||
|
||||
**Schema changes:** Add `status TEXT NOT NULL DEFAULT 'researching'` to `thread_candidates`
|
||||
|
||||
### Phase 4: Weight Classification
|
||||
|
||||
**Rationale:** Most architecturally significant change in v1.2. Changes the sync API shape (breaking change, single call site). Requires Phase 1 to be complete so classification totals display in the correct unit. Schema migration should be batched with Phase 3.
|
||||
|
||||
**Delivers:** `weightClass` column on `setup_items`, updated sync endpoint accepting `{ items: Array<{ itemId, weightClass }> }`, new `PATCH /api/setups/:id/items/:itemId` endpoint, three-segment classification toggle per item in setup detail view, base/worn/consumable weight subtotals.
|
||||
|
||||
**Addresses:** Weight classification base/worn/consumable (table stakes), per-setup classification (differentiator)
|
||||
|
||||
**Avoids:** Classification on items table (Pitfall 2), test helper desync (Pitfall 6), losing classification data on sync
|
||||
|
||||
**Schema changes:** Add `weight_class TEXT NOT NULL DEFAULT 'base'` to `setup_items`
|
||||
|
||||
### Phase 5: Weight Distribution Charts
|
||||
|
||||
**Rationale:** Depends on Phase 1 (unit-aware labels) and Phase 4 (classification data for setup breakdown). Only phase requiring a new npm dependency. Highest UI complexity but lowest architectural risk -- read-only visualization of existing data.
|
||||
|
||||
**Delivers:** `react-minimal-pie-chart` integration, `WeightChart` component, collection-level donut chart (weight by category from `useTotals()`), setup-level donut chart (weight by classification), chart legend with consistent colors, hover tooltips with formatted weights.
|
||||
|
||||
**Addresses:** Weight distribution visualization (differentiator)
|
||||
|
||||
**Avoids:** Chart/totals divergence (Pitfall 5), chart crashing on null-weight items, unnecessary chart re-renders on unrelated state changes
|
||||
|
||||
**Schema changes:** None (npm dependency: `bun add react-minimal-pie-chart`)
|
||||
**Rationale:** No schema dependency. Easiest to build last because the comparison view UI (Phase 3) already establishes the thread header area where the setup selector lives. Both add-mode and replace-mode deltas must be designed here to avoid the misleading pure-addition delta.
|
||||
**Delivers:** Setup selector dropdown in thread header (`useSetups()` data); `SetupImpactRow.tsx` component; `impactSetupId` in `uiStore`; add-mode delta and replace-mode delta (auto-defaults to replace when same-category item exists in setup); null weight guard ("-- (no weight data)" not "+0g"); unit-aware display via `useWeightUnit()` / `useCurrency()`
|
||||
**Uses:** Existing `useSetup(id)` hook (no new API), existing `formatWeight` / `formatPrice` formatters, `categoryId` on thread for replacement item detection
|
||||
**Avoids:** Stale data in impact preview (Pitfall 3 — reactive `useQuery` for setup data), wrong delta from add-vs-replace confusion (Pitfall 6), null weight treated as 0 (integration gotcha), server endpoint for delta calculation (architecture anti-pattern)
|
||||
|
||||
### Phase Ordering Rationale
|
||||
|
||||
- **Phase 1 first** because `formatWeight` is called by every weight-displaying component. Refactoring it after other features are built means touching the same files twice.
|
||||
- **Phase 2 is independent** and could be built in any order, but sequencing it second allows the team to ship a quick win while Phase 3/4 schema changes are designed.
|
||||
- **Batch Phase 3 + Phase 4 schema migrations** into one `bun run db:generate` run. Both add columns to existing tables; a single migration simplifies deployment.
|
||||
- **Phase 4 after Phase 1** because classification totals need the unit-aware formatter.
|
||||
- **Phase 5 last** because it is pure visualization depending on data from Phases 1 and 4, and introduces the only external dependency.
|
||||
- Phase 1 before all others: SQLite schema changes batched into a single migration; test helper updated once; pros/cons in edit panel adds value immediately without waiting for the comparison view
|
||||
- Phase 2 before Phase 3: rank data (sort order, rank badge) is more valuable displayed in the comparison table than in the card grid alone; building the comparison view after ranking ensures the table is complete on first delivery
|
||||
- Phase 3 before Phase 4: comparison view establishes the thread header chrome (toggle button area) where the setup selector in Phase 4 will live; building header UI in Phase 3 reduces Phase 4 scope
|
||||
- Phases 3 and 4 are technically independent and could parallelize, but sequencing them keeps the thread detail header changes contained to one phase at a time
|
||||
|
||||
### Research Flags
|
||||
|
||||
Phases likely needing deeper research during planning:
|
||||
- **Phase 4 (Weight Classification):** The sync API shape change is breaking. The existing delete-all/re-insert pattern destroys classification data. Needs careful design of the PATCH endpoint and how ItemPicker interacts with classification preservation during item add/remove. Worth a `/gsd:research-phase`.
|
||||
- **Phase 5 (Weight Distribution Charts):** react-minimal-pie-chart API specifics (label rendering, responsive sizing, animation control) should be validated with a quick prototype. Consider a short research spike.
|
||||
Phases that need careful plan review before execution (not full research-phase, but plan must address specific design decisions):
|
||||
- **Phase 2:** The `tempItems` local state pattern and fractional `sort_order` arithmetic are non-obvious. The PLAN.md must spell these out explicitly before coding. PITFALLS.md Pitfall 1 and Pitfall 2 must be addressed in the plan, not discovered during implementation.
|
||||
- **Phase 4:** The add-vs-replace distinction requires deliberate design (which mode is default, how replacement item is detected by category, how null weight is surfaced). PITFALLS.md Pitfall 6 must be resolved in the plan before the component is built.
|
||||
|
||||
Phases with standard patterns (skip research-phase):
|
||||
- **Phase 1 (Weight Unit Selection):** Well-documented pattern. Extend `formatWeight`, add a `useSetting` wrapper, propagate through components. No unknowns.
|
||||
- **Phase 2 (Search/Filter):** Textbook client-side filtering with `useMemo`. No API changes. Standard React pattern.
|
||||
- **Phase 3 (Candidate Status):** Simple column addition with Zod enum validation. Existing `useUpdateCandidate` mutation already handles partial updates.
|
||||
Phases with standard patterns (can skip `/gsd:research-phase`):
|
||||
- **Phase 1:** Standard Drizzle migration + Zod schema extension; established patterns in the codebase; ARCHITECTURE.md provides exact column definitions
|
||||
- **Phase 3:** Pure presentational component; Tailwind comparison table is well-documented; ARCHITECTURE.md provides complete component structure, props interface, and delta calculation code
|
||||
|
||||
## Confidence Assessment
|
||||
|
||||
| Area | Confidence | Notes |
|
||||
|------|------------|-------|
|
||||
| Stack | HIGH | Only one new dependency (react-minimal-pie-chart). React 19 compatibility verified via package.json peerDeps. All other features use existing stack with no changes. |
|
||||
| Features | HIGH | Feature set derived from analysis of 8+ competing tools (LighterPack, Hikt, PackLight, Packstack, HikeLite, Packrat, OutPack, BPL Calculator). Clear consensus on table stakes vs. differentiators. |
|
||||
| Architecture | HIGH | Based on direct codebase analysis with integration points mapped to specific files. The 5 new / 15 modified file inventory is concrete and verified against the existing codebase. |
|
||||
| Pitfalls | HIGH | Derived from codebase-specific patterns (test helper duplication, dual computation paths) combined with domain risks (unit conversion rounding, classification scope). Not generic warnings. |
|
||||
| Stack | HIGH | Verified from `bun.lock` (framer-motion React 19 peerDeps confirmed); dnd-kit abandonment verified via npm + GitHub; Motion Reorder API verified via motion.dev docs |
|
||||
| Features | HIGH | Codebase analysis confirmed no rank/pros/cons columns in existing schema; NNGroup + Smashing Magazine for comparison UX patterns; competitor analysis (LighterPack, GearGrams, OutPack) confirmed feature gap |
|
||||
| Architecture | HIGH | Full integration map derived from direct codebase analysis; build order confirmed by column dependency graph; all changed files enumerated (3 new, 10 modified); complete code patterns provided |
|
||||
| Pitfalls | HIGH | dnd-kit flicker: verified in GitHub Discussion #1522 and Issue #921; fractional indexing: verified via steveruiz.me and fractional-indexing library; comparison UX: Baymard Institute and NNGroup |
|
||||
|
||||
**Overall confidence:** HIGH
|
||||
|
||||
### Gaps to Address
|
||||
|
||||
- **`lb` display format:** FEATURES.md suggests "2 lb 3 oz" (pounds + remainder ounces) while STACK.md suggests simpler decimal format. The traditional "lb + oz" format is more useful to American users but adds formatting complexity. Decide during Phase 1 implementation.
|
||||
- **Status change timestamps:** PITFALLS.md recommends storing `statusChangedAt` alongside `status` for staleness detection ("ordered 30 days ago -- still waiting?"). Low effort to add during the schema migration. Decide during Phase 3 planning.
|
||||
- **Sync API backward compatibility:** The sync endpoint shape changes from `{ itemIds: number[] }` to `{ items: [...] }`. Single call site (ItemPicker.tsx), but verify no external consumers exist before shipping.
|
||||
- **react-minimal-pie-chart responsive behavior:** SVG-based and should handle responsive sizing, but exact approach (CSS width vs. explicit size prop) should be validated in Phase 5. Not a risk, just a detail to confirm.
|
||||
- **Impact preview add-vs-replace UX:** Research establishes that both modes are needed and when to default to each (same-category item in setup = replace mode). The exact affordance — dropdown to select which item is replaced vs. automatic category matching — is not fully specified. Recommendation: auto-match by category with a "change" link to override. Decide during Phase 4 planning.
|
||||
- **Comparison view maximum candidate count:** Research recommends 3-4 max for usability. GearBox has no current limit on candidates per thread. Whether to enforce a hard display limit (hide additional candidates behind "show more") or allow unrestricted horizontal scroll should be decided during Phase 3 planning.
|
||||
- **Sort order initialization for existing candidates:** When the migration runs, existing `thread_candidates` rows get `sort_order = 0` (default). Phase 1 plan must specify whether to initialize existing candidates with spaced values (e.g., 1000, 2000, 3000) at migration time or accept that all existing rows start at 0 and rely on first drag to establish order.
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- [Drizzle ORM Filter Operators](https://orm.drizzle.team/docs/operators) -- like, eq, and operators for search/filter
|
||||
- [Drizzle ORM Conditional Filters Guide](https://orm.drizzle.team/docs/guides/conditional-filters-in-query) -- dynamic filter composition
|
||||
- [react-minimal-pie-chart GitHub](https://github.com/toomuchdesign/react-minimal-pie-chart) -- v9.1.2, React 19 peerDeps verified in package.json
|
||||
- [LighterPack](https://lighterpack.com/) -- base/worn/consumable classification standard, pie chart visualization pattern
|
||||
- [99Boulders LighterPack Tutorial](https://www.99boulders.com/lighterpack-tutorial) -- classification definitions and feature walkthrough
|
||||
- [BackpackPeek Pack Weight Calculator Guide](https://backpackpeek.com/blog/pack-weight-calculator-base-weight-guide) -- weight classification methodology
|
||||
- Direct codebase analysis of GearBox v1.1 -- schema.ts, services, hooks, routes, test helpers
|
||||
- `bun.lock` (project lockfile) — framer-motion v12.37.0 peerDeps `"react: ^18.0.0 || ^19.0.0"` confirmed
|
||||
- [Motion Reorder docs](https://motion.dev/docs/react-reorder) — `Reorder.Group`, `Reorder.Item`, `onDragEnd` API
|
||||
- [dnd-kit Discussion #1522](https://github.com/clauderic/dnd-kit/discussions/1522) — `tempItems` solution for React Query cache flicker
|
||||
- [dnd-kit Issue #921](https://github.com/clauderic/dnd-kit/issues/921) — root cause of state lifecycle mismatch
|
||||
- [Fractional Indexing — steveruiz.me](https://www.steveruiz.me/posts/reordering-fractional-indices) — why float sort keys beat integer reorder for databases
|
||||
- [Baymard Institute: Comparison Tool Design](https://baymard.com/blog/user-friendly-comparison-tools) — sticky headers, horizontal scroll, minimum column width
|
||||
- [NNGroup: Comparison Tables](https://www.nngroup.com/articles/comparison-tables/) — information architecture, anti-patterns
|
||||
- [Smashing Magazine: Feature Comparison Table](https://www.smashingmagazine.com/2017/08/designing-perfect-feature-comparison-table/) — table layout patterns
|
||||
- GearBox codebase direct analysis (`src/db/schema.ts`, `src/server/services/`, `src/client/hooks/`, `tests/helpers/db.ts`) — confirmed existing patterns, missing columns, integration points
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- [Hikt](https://hikt.app/) -- searchable gear closet, base vs worn weight display
|
||||
- [PackLight (iOS)](https://apps.apple.com/us/app/packlight-for-backpacking/id1054845207) -- search, categories, bar graph visualization
|
||||
- [Packstack](https://www.packstack.io/) -- base/worn/consumable weight separation
|
||||
- [Packrat](https://www.packrat.app/) -- flexible weight unit input and display conversion
|
||||
- [Recharts React 19 issue #6857](https://github.com/recharts/recharts/issues/6857) -- rendering issues with React 19.2.3
|
||||
- [TanStack Query filtering discussions](https://github.com/TanStack/query/discussions/1113) -- client-side vs server-side filtering patterns
|
||||
- [LogRocket Best React Chart Libraries 2025](https://blog.logrocket.com/best-react-chart-libraries-2025/) -- chart library comparison
|
||||
- [@dnd-kit/core npm](https://www.npmjs.com/package/@dnd-kit/core) — v6.3.1 last published ~1 year ago, no React 19
|
||||
- [dnd-kit React 19 issue #1511](https://github.com/clauderic/dnd-kit/issues/1511) — CLOSED but React 19 TypeScript issues confirmed
|
||||
- [@dnd-kit/react roadmap discussion #1842](https://github.com/clauderic/dnd-kit/discussions/1842) — 0 maintainer replies; pre-1.0 risk signal
|
||||
- [hello-pangea/dnd React 19 issue #864](https://github.com/hello-pangea/dnd/issues/864) — still open as of Jan 2026
|
||||
- [BrightCoding dnd-kit deep dive (2025)](https://www.blog.brightcoding.dev/2025/08/21/the-ultimate-drag-and-drop-toolkit-for-react-a-deep-dive-into-dnd-kit/) — react-beautiful-dnd abandoned; dnd-kit current standard but React 19 gap confirmed
|
||||
- [TrailsMag: Leaving LighterPack](https://trailsmag.net/blogs/hiker-box/ultralight-the-gear-tracking-app-i-m-leaving-lighterpack-for) — LighterPack feature gap analysis
|
||||
- [Contentsquare: Comparing products UX](https://contentsquare.com/blog/comparing-products-design-practices-to-help-your-users-avoid-fragmented-comparison-7/) — fragmented comparison pitfalls
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- [SQLite LIKE case sensitivity](https://github.com/drizzle-team/drizzle-orm-docs/issues/239) -- LIKE is case-insensitive in SQLite (relevant only if search moves server-side)
|
||||
- [Drizzle ORM SQLite migration pitfalls #1313](https://github.com/drizzle-team/drizzle-orm/issues/1313) -- data loss bug with push + add column (monitor during migration)
|
||||
- [Fractional Indexing SQLite library](https://github.com/sqliteai/fractional-indexing) — implementation reference for lexicographic sort keys (pattern reference only; direct float arithmetic sufficient for this use case)
|
||||
- [Top 5 Drag-and-Drop Libraries for React 2026](https://puckeditor.com/blog/top-5-drag-and-drop-libraries-for-react) — ecosystem overview confirming dnd-kit and hello-pangea/dnd limitations
|
||||
|
||||
---
|
||||
*Research completed: 2026-03-16*
|
||||
|
||||
2
drizzle/0004_soft_synch.sql
Normal file
2
drizzle/0004_soft_synch.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `thread_candidates` ADD `pros` text;--> statement-breakpoint
|
||||
ALTER TABLE `thread_candidates` ADD `cons` text;
|
||||
1
drizzle/0005_clear_micromax.sql
Normal file
1
drizzle/0005_clear_micromax.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `thread_candidates` ADD `sort_order` real DEFAULT 0 NOT NULL;
|
||||
497
drizzle/meta/0004_snapshot.json
Normal file
497
drizzle/meta/0004_snapshot.json
Normal file
@@ -0,0 +1,497 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "529d2e93-7d7f-4a83-bb29-254ce09cdef4",
|
||||
"prevId": "628b9ef4-c715-4bbe-a118-042d80fde91e",
|
||||
"tables": {
|
||||
"categories": {
|
||||
"name": "categories",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"icon": {
|
||||
"name": "icon",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'package'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"categories_name_unique": {
|
||||
"name": "categories_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"items": {
|
||||
"name": "items",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"weight_grams": {
|
||||
"name": "weight_grams",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"price_cents": {
|
||||
"name": "price_cents",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"category_id": {
|
||||
"name": "category_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"product_url": {
|
||||
"name": "product_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"image_filename": {
|
||||
"name": "image_filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"items_category_id_categories_id_fk": {
|
||||
"name": "items_category_id_categories_id_fk",
|
||||
"tableFrom": "items",
|
||||
"tableTo": "categories",
|
||||
"columnsFrom": [
|
||||
"category_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"setup_items": {
|
||||
"name": "setup_items",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"setup_id": {
|
||||
"name": "setup_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"item_id": {
|
||||
"name": "item_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"classification": {
|
||||
"name": "classification",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'base'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"setup_items_setup_id_setups_id_fk": {
|
||||
"name": "setup_items_setup_id_setups_id_fk",
|
||||
"tableFrom": "setup_items",
|
||||
"tableTo": "setups",
|
||||
"columnsFrom": [
|
||||
"setup_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"setup_items_item_id_items_id_fk": {
|
||||
"name": "setup_items_item_id_items_id_fk",
|
||||
"tableFrom": "setup_items",
|
||||
"tableTo": "items",
|
||||
"columnsFrom": [
|
||||
"item_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"setups": {
|
||||
"name": "setups",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"thread_candidates": {
|
||||
"name": "thread_candidates",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"thread_id": {
|
||||
"name": "thread_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"weight_grams": {
|
||||
"name": "weight_grams",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"price_cents": {
|
||||
"name": "price_cents",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"category_id": {
|
||||
"name": "category_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"product_url": {
|
||||
"name": "product_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"image_filename": {
|
||||
"name": "image_filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'researching'"
|
||||
},
|
||||
"pros": {
|
||||
"name": "pros",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"cons": {
|
||||
"name": "cons",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"thread_candidates_thread_id_threads_id_fk": {
|
||||
"name": "thread_candidates_thread_id_threads_id_fk",
|
||||
"tableFrom": "thread_candidates",
|
||||
"tableTo": "threads",
|
||||
"columnsFrom": [
|
||||
"thread_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"thread_candidates_category_id_categories_id_fk": {
|
||||
"name": "thread_candidates_category_id_categories_id_fk",
|
||||
"tableFrom": "thread_candidates",
|
||||
"tableTo": "categories",
|
||||
"columnsFrom": [
|
||||
"category_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"threads": {
|
||||
"name": "threads",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"resolved_candidate_id": {
|
||||
"name": "resolved_candidate_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"category_id": {
|
||||
"name": "category_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"threads_category_id_categories_id_fk": {
|
||||
"name": "threads_category_id_categories_id_fk",
|
||||
"tableFrom": "threads",
|
||||
"tableTo": "categories",
|
||||
"columnsFrom": [
|
||||
"category_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
505
drizzle/meta/0005_snapshot.json
Normal file
505
drizzle/meta/0005_snapshot.json
Normal file
@@ -0,0 +1,505 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "297e86db-c777-4432-950e-b0129dedb2dc",
|
||||
"prevId": "529d2e93-7d7f-4a83-bb29-254ce09cdef4",
|
||||
"tables": {
|
||||
"categories": {
|
||||
"name": "categories",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"icon": {
|
||||
"name": "icon",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'package'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"categories_name_unique": {
|
||||
"name": "categories_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"items": {
|
||||
"name": "items",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"weight_grams": {
|
||||
"name": "weight_grams",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"price_cents": {
|
||||
"name": "price_cents",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"category_id": {
|
||||
"name": "category_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"product_url": {
|
||||
"name": "product_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"image_filename": {
|
||||
"name": "image_filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"items_category_id_categories_id_fk": {
|
||||
"name": "items_category_id_categories_id_fk",
|
||||
"tableFrom": "items",
|
||||
"tableTo": "categories",
|
||||
"columnsFrom": [
|
||||
"category_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"setup_items": {
|
||||
"name": "setup_items",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"setup_id": {
|
||||
"name": "setup_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"item_id": {
|
||||
"name": "item_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"classification": {
|
||||
"name": "classification",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'base'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"setup_items_setup_id_setups_id_fk": {
|
||||
"name": "setup_items_setup_id_setups_id_fk",
|
||||
"tableFrom": "setup_items",
|
||||
"tableTo": "setups",
|
||||
"columnsFrom": [
|
||||
"setup_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"setup_items_item_id_items_id_fk": {
|
||||
"name": "setup_items_item_id_items_id_fk",
|
||||
"tableFrom": "setup_items",
|
||||
"tableTo": "items",
|
||||
"columnsFrom": [
|
||||
"item_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"setups": {
|
||||
"name": "setups",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"thread_candidates": {
|
||||
"name": "thread_candidates",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"thread_id": {
|
||||
"name": "thread_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"weight_grams": {
|
||||
"name": "weight_grams",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"price_cents": {
|
||||
"name": "price_cents",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"category_id": {
|
||||
"name": "category_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"product_url": {
|
||||
"name": "product_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"image_filename": {
|
||||
"name": "image_filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'researching'"
|
||||
},
|
||||
"pros": {
|
||||
"name": "pros",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"cons": {
|
||||
"name": "cons",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"thread_candidates_thread_id_threads_id_fk": {
|
||||
"name": "thread_candidates_thread_id_threads_id_fk",
|
||||
"tableFrom": "thread_candidates",
|
||||
"tableTo": "threads",
|
||||
"columnsFrom": [
|
||||
"thread_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"thread_candidates_category_id_categories_id_fk": {
|
||||
"name": "thread_candidates_category_id_categories_id_fk",
|
||||
"tableFrom": "thread_candidates",
|
||||
"tableTo": "categories",
|
||||
"columnsFrom": [
|
||||
"category_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"threads": {
|
||||
"name": "threads",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"resolved_candidate_id": {
|
||||
"name": "resolved_candidate_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"category_id": {
|
||||
"name": "category_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"threads_category_id_categories_id_fk": {
|
||||
"name": "threads_category_id_categories_id_fk",
|
||||
"tableFrom": "threads",
|
||||
"tableTo": "categories",
|
||||
"columnsFrom": [
|
||||
"category_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,20 @@
|
||||
"when": 1773670263013,
|
||||
"tag": "0003_misty_mongu",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1773693113029,
|
||||
"tag": "0004_soft_synch",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1773696058992,
|
||||
"tag": "0005_clear_micromax",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
import { useUIStore } from "../stores/uiStore";
|
||||
import { RankBadge } from "./CandidateListItem";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
|
||||
interface CandidateCardProps {
|
||||
@@ -17,6 +19,9 @@ interface CandidateCardProps {
|
||||
isActive: boolean;
|
||||
status: "researching" | "ordered" | "arrived";
|
||||
onStatusChange: (status: "researching" | "ordered" | "arrived") => void;
|
||||
pros?: string | null;
|
||||
cons?: string | null;
|
||||
rank?: number;
|
||||
}
|
||||
|
||||
export function CandidateCard({
|
||||
@@ -32,8 +37,12 @@ export function CandidateCard({
|
||||
isActive,
|
||||
status,
|
||||
onStatusChange,
|
||||
pros,
|
||||
cons,
|
||||
rank,
|
||||
}: CandidateCardProps) {
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
|
||||
const openConfirmDeleteCandidate = useUIStore(
|
||||
(s) => s.openConfirmDeleteCandidate,
|
||||
@@ -42,14 +51,74 @@ export function CandidateCard({
|
||||
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
||||
|
||||
return (
|
||||
<div className="relative bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCandidateEditPanel(id)}
|
||||
className="relative w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden group"
|
||||
>
|
||||
{/* Hover-reveal action buttons */}
|
||||
{isActive && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openResolveDialog(threadId, id);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation();
|
||||
openResolveDialog(threadId, id);
|
||||
}
|
||||
}}
|
||||
className="absolute top-2 left-2 z-10 px-2 py-0.5 flex items-center gap-1 rounded-full text-xs font-medium bg-amber-100/90 text-amber-700 hover:bg-amber-200 opacity-0 group-hover:opacity-100 transition-all cursor-pointer"
|
||||
title="Pick as winner"
|
||||
>
|
||||
<LucideIcon name="trophy" size={12} />
|
||||
Winner
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openConfirmDeleteCandidate(id);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation();
|
||||
openConfirmDeleteCandidate(id);
|
||||
}
|
||||
}}
|
||||
className={`absolute top-2 ${productUrl ? "right-10" : "right-2"} z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-red-100 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all cursor-pointer`}
|
||||
title="Delete candidate"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
{productUrl && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => openExternalLink(productUrl)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openExternalLink(productUrl);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation();
|
||||
openExternalLink(productUrl);
|
||||
}
|
||||
}}
|
||||
@@ -92,7 +161,8 @@ export function CandidateCard({
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2 truncate">
|
||||
{name}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{rank != null && <RankBadge rank={rank} />}
|
||||
{weightGrams != null && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
|
||||
{formatWeight(weightGrams, unit)}
|
||||
@@ -100,7 +170,7 @@ export function CandidateCard({
|
||||
)}
|
||||
{priceCents != null && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
|
||||
{formatPrice(priceCents)}
|
||||
{formatPrice(priceCents, currency)}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
||||
@@ -112,33 +182,13 @@ export function CandidateCard({
|
||||
{categoryName}
|
||||
</span>
|
||||
<StatusBadge status={status} onStatusChange={onStatusChange} />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCandidateEditPanel(id)}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openConfirmDeleteCandidate(id)}
|
||||
className="text-xs text-gray-500 hover:text-red-600 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
{isActive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openResolveDialog(threadId, id)}
|
||||
className="ml-auto text-xs font-medium text-amber-600 hover:text-amber-700 transition-colors"
|
||||
>
|
||||
Pick Winner
|
||||
</button>
|
||||
{(pros || cons) && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-700">
|
||||
+/- Notes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ interface FormData {
|
||||
notes: string;
|
||||
productUrl: string;
|
||||
imageFilename: string | null;
|
||||
pros: string;
|
||||
cons: string;
|
||||
}
|
||||
|
||||
const INITIAL_FORM: FormData = {
|
||||
@@ -29,6 +31,8 @@ const INITIAL_FORM: FormData = {
|
||||
notes: "",
|
||||
productUrl: "",
|
||||
imageFilename: null,
|
||||
pros: "",
|
||||
cons: "",
|
||||
};
|
||||
|
||||
export function CandidateForm({
|
||||
@@ -61,6 +65,8 @@ export function CandidateForm({
|
||||
notes: candidate.notes ?? "",
|
||||
productUrl: candidate.productUrl ?? "",
|
||||
imageFilename: candidate.imageFilename,
|
||||
pros: candidate.pros ?? "",
|
||||
cons: candidate.cons ?? "",
|
||||
});
|
||||
}
|
||||
} else if (mode === "add") {
|
||||
@@ -110,6 +116,8 @@ export function CandidateForm({
|
||||
notes: form.notes.trim() || undefined,
|
||||
productUrl: form.productUrl.trim() || undefined,
|
||||
imageFilename: form.imageFilename ?? undefined,
|
||||
pros: form.pros.trim() || undefined,
|
||||
cons: form.cons.trim() || undefined,
|
||||
};
|
||||
|
||||
if (mode === "add") {
|
||||
@@ -239,6 +247,42 @@ export function CandidateForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pros */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="candidate-pros"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Pros
|
||||
</label>
|
||||
<textarea
|
||||
id="candidate-pros"
|
||||
value={form.pros}
|
||||
onChange={(e) => setForm((f) => ({ ...f, pros: e.target.value }))}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent resize-none"
|
||||
placeholder="One pro per line..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cons */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="candidate-cons"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Cons
|
||||
</label>
|
||||
<textarea
|
||||
id="candidate-cons"
|
||||
value={form.cons}
|
||||
onChange={(e) => setForm((f) => ({ ...f, cons: e.target.value }))}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent resize-none"
|
||||
placeholder="One con per line..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Product Link */}
|
||||
<div>
|
||||
<label
|
||||
|
||||
211
src/client/components/CandidateListItem.tsx
Normal file
211
src/client/components/CandidateListItem.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { Reorder, useDragControls } from "framer-motion";
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
import { useUIStore } from "../stores/uiStore";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
|
||||
interface CandidateWithCategory {
|
||||
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;
|
||||
categoryName: string;
|
||||
categoryIcon: string;
|
||||
}
|
||||
|
||||
interface CandidateListItemProps {
|
||||
candidate: CandidateWithCategory;
|
||||
rank: number;
|
||||
isActive: boolean;
|
||||
onStatusChange: (status: "researching" | "ordered" | "arrived") => void;
|
||||
}
|
||||
|
||||
const RANK_COLORS = ["#D4AF37", "#C0C0C0", "#CD7F32"]; // gold, silver, bronze
|
||||
|
||||
export 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 function CandidateListItem({
|
||||
candidate,
|
||||
rank,
|
||||
isActive,
|
||||
onStatusChange,
|
||||
}: CandidateListItemProps) {
|
||||
const controls = useDragControls();
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
|
||||
const openConfirmDeleteCandidate = useUIStore(
|
||||
(s) => s.openConfirmDeleteCandidate,
|
||||
);
|
||||
const openResolveDialog = useUIStore((s) => s.openResolveDialog);
|
||||
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
value={candidate}
|
||||
dragControls={controls}
|
||||
dragListener={false}
|
||||
className="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 cursor-default"
|
||||
>
|
||||
{/* Drag handle */}
|
||||
{isActive && (
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(e) => controls.start(e)}
|
||||
className="cursor-grab active:cursor-grabbing text-gray-300 hover:text-gray-500 touch-none shrink-0"
|
||||
title="Drag to reorder"
|
||||
>
|
||||
<LucideIcon name="grip-vertical" size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Rank badge */}
|
||||
<RankBadge rank={rank} />
|
||||
|
||||
{/* Image thumbnail */}
|
||||
<div className="w-12 h-12 rounded-lg overflow-hidden shrink-0 bg-gray-50 flex items-center justify-center">
|
||||
{candidate.imageFilename ? (
|
||||
<img
|
||||
src={`/uploads/${candidate.imageFilename}`}
|
||||
alt={candidate.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<LucideIcon
|
||||
name={candidate.categoryIcon}
|
||||
size={20}
|
||||
className="text-gray-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name + badges */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCandidateEditPanel(candidate.id)}
|
||||
className="flex-1 min-w-0 text-left"
|
||||
>
|
||||
<p className="text-sm font-semibold text-gray-900 truncate">
|
||||
{candidate.name}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
{candidate.weightGrams != null && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
|
||||
{formatWeight(candidate.weightGrams, unit)}
|
||||
</span>
|
||||
)}
|
||||
{candidate.priceCents != null && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
|
||||
{formatPrice(candidate.priceCents, currency)}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
||||
<LucideIcon
|
||||
name={candidate.categoryIcon}
|
||||
size={14}
|
||||
className="inline-block mr-1 text-gray-500"
|
||||
/>
|
||||
{candidate.categoryName}
|
||||
</span>
|
||||
<StatusBadge
|
||||
status={candidate.status}
|
||||
onStatusChange={onStatusChange}
|
||||
/>
|
||||
{(candidate.pros || candidate.cons) && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-700">
|
||||
+/- Notes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Action buttons (hover-reveal) */}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
{isActive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openResolveDialog(candidate.threadId, candidate.id);
|
||||
}}
|
||||
className="px-2 py-0.5 flex items-center gap-1 rounded-full text-xs font-medium bg-amber-100/90 text-amber-700 hover:bg-amber-200 cursor-pointer"
|
||||
title="Pick as winner"
|
||||
>
|
||||
<LucideIcon name="trophy" size={12} />
|
||||
Winner
|
||||
</button>
|
||||
)}
|
||||
{candidate.productUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openExternalLink(candidate.productUrl as string);
|
||||
}}
|
||||
className="w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-gray-200 hover:text-gray-600 cursor-pointer"
|
||||
title="Open product link"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openConfirmDeleteCandidate(candidate.id);
|
||||
}}
|
||||
className="w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-red-100 hover:text-red-500 cursor-pointer"
|
||||
title="Delete candidate"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</Reorder.Item>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useDeleteCategory, useUpdateCategory } from "../hooks/useCategories";
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
@@ -23,6 +24,7 @@ export function CategoryHeader({
|
||||
itemCount,
|
||||
}: CategoryHeaderProps) {
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editName, setEditName] = useState(name);
|
||||
const [editIcon, setEditIcon] = useState(icon);
|
||||
@@ -87,7 +89,7 @@ export function CategoryHeader({
|
||||
<h2 className="text-lg font-semibold text-gray-900">{name}</h2>
|
||||
<span className="text-sm text-gray-400">
|
||||
{itemCount} {itemCount === 1 ? "item" : "items"} ·{" "}
|
||||
{formatWeight(totalWeight, unit)} · {formatPrice(totalCost)}
|
||||
{formatWeight(totalWeight, unit)} · {formatPrice(totalCost, currency)}
|
||||
</span>
|
||||
{!isUncategorized && (
|
||||
<div className="ml-auto flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
|
||||
336
src/client/components/ComparisonTable.tsx
Normal file
336
src/client/components/ComparisonTable.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import { useMemo } from "react";
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
import { useUIStore } from "../stores/uiStore";
|
||||
import { RankBadge } from "./CandidateListItem";
|
||||
|
||||
interface CandidateWithCategory {
|
||||
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;
|
||||
categoryName: string;
|
||||
categoryIcon: string;
|
||||
}
|
||||
|
||||
interface ComparisonTableProps {
|
||||
candidates: CandidateWithCategory[];
|
||||
resolvedCandidateId: number | null;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<"researching" | "ordered" | "arrived", string> = {
|
||||
researching: "Researching",
|
||||
ordered: "Ordered",
|
||||
arrived: "Arrived",
|
||||
};
|
||||
|
||||
export function ComparisonTable({
|
||||
candidates,
|
||||
resolvedCandidateId,
|
||||
}: ComparisonTableProps) {
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
||||
|
||||
const { bestWeightId, bestPriceId, weightDeltas, priceDeltas } =
|
||||
useMemo(() => {
|
||||
// Weight deltas
|
||||
const withWeight = candidates.filter((c) => c.weightGrams != null);
|
||||
let bestWeightId: number | null = null;
|
||||
const weightDeltas: Record<number, string | null> = {};
|
||||
|
||||
if (withWeight.length > 0) {
|
||||
const minWeight = Math.min(
|
||||
...withWeight.map((c) => c.weightGrams as number),
|
||||
);
|
||||
bestWeightId =
|
||||
withWeight.find((c) => c.weightGrams === minWeight)?.id ?? null;
|
||||
for (const c of candidates) {
|
||||
if (c.weightGrams == null) {
|
||||
weightDeltas[c.id] = null;
|
||||
} else {
|
||||
const delta = c.weightGrams - minWeight;
|
||||
weightDeltas[c.id] =
|
||||
delta === 0 ? null : `+${formatWeight(delta, unit)}`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const c of candidates) {
|
||||
weightDeltas[c.id] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Price deltas
|
||||
const withPrice = candidates.filter((c) => c.priceCents != null);
|
||||
let bestPriceId: number | null = null;
|
||||
const priceDeltas: Record<number, string | null> = {};
|
||||
|
||||
if (withPrice.length > 0) {
|
||||
const minPrice = Math.min(
|
||||
...withPrice.map((c) => c.priceCents as number),
|
||||
);
|
||||
bestPriceId =
|
||||
withPrice.find((c) => c.priceCents === minPrice)?.id ?? null;
|
||||
for (const c of candidates) {
|
||||
if (c.priceCents == null) {
|
||||
priceDeltas[c.id] = null;
|
||||
} else {
|
||||
const delta = c.priceCents - minPrice;
|
||||
priceDeltas[c.id] =
|
||||
delta === 0 ? null : `+${formatPrice(delta, currency)}`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const c of candidates) {
|
||||
priceDeltas[c.id] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { bestWeightId, bestPriceId, weightDeltas, priceDeltas };
|
||||
}, [candidates, unit, currency]);
|
||||
|
||||
const ATTRIBUTE_ROWS: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
render: (
|
||||
candidate: CandidateWithCategory,
|
||||
index: number,
|
||||
) => React.ReactNode;
|
||||
cellClass?: (candidate: CandidateWithCategory) => string;
|
||||
}> = [
|
||||
{
|
||||
key: "image",
|
||||
label: "Image",
|
||||
render: (c) => (
|
||||
<div className="w-12 h-12 rounded-lg overflow-hidden bg-gray-50 flex items-center justify-center">
|
||||
{c.imageFilename ? (
|
||||
<img
|
||||
src={`/uploads/${c.imageFilename}`}
|
||||
alt={c.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<LucideIcon
|
||||
name={c.categoryIcon}
|
||||
size={20}
|
||||
className="text-gray-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "name",
|
||||
label: "Name",
|
||||
render: (c) => (
|
||||
<span className="text-sm font-medium text-gray-900">{c.name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "rank",
|
||||
label: "Rank",
|
||||
render: (_c, index) => <RankBadge rank={index + 1} />,
|
||||
},
|
||||
{
|
||||
key: "weight",
|
||||
label: "Weight",
|
||||
render: (c) => {
|
||||
const isBest = c.id === bestWeightId;
|
||||
const delta = weightDeltas[c.id];
|
||||
if (c.weightGrams == null) {
|
||||
return <span className="text-gray-300">—</span>;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<span className="font-medium text-gray-900">
|
||||
{formatWeight(c.weightGrams, unit)}
|
||||
</span>
|
||||
{!isBest && delta && (
|
||||
<div className="text-xs text-gray-400">{delta}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cellClass: (c) => {
|
||||
if (c.id === bestWeightId) return "bg-blue-50";
|
||||
if (c.id === resolvedCandidateId) return "bg-amber-50/50";
|
||||
return "";
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "price",
|
||||
label: "Price",
|
||||
render: (c) => {
|
||||
const isBest = c.id === bestPriceId;
|
||||
const delta = priceDeltas[c.id];
|
||||
if (c.priceCents == null) {
|
||||
return <span className="text-gray-300">—</span>;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<span className="font-medium text-gray-900">
|
||||
{formatPrice(c.priceCents, currency)}
|
||||
</span>
|
||||
{!isBest && delta && (
|
||||
<div className="text-xs text-gray-400">{delta}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cellClass: (c) => {
|
||||
if (c.id === bestPriceId) return "bg-green-50";
|
||||
if (c.id === resolvedCandidateId) return "bg-amber-50/50";
|
||||
return "";
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
render: (c) => (
|
||||
<span className="text-xs text-gray-600">{STATUS_LABELS[c.status]}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "link",
|
||||
label: "Link",
|
||||
render: (c) =>
|
||||
c.productUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openExternalLink(c.productUrl as string)}
|
||||
className="text-xs text-blue-500 hover:underline"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-gray-300">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "notes",
|
||||
label: "Notes",
|
||||
render: (c) =>
|
||||
c.notes ? (
|
||||
<p className="text-xs text-gray-700 whitespace-pre-line">{c.notes}</p>
|
||||
) : (
|
||||
<span className="text-gray-300">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "pros",
|
||||
label: "Pros",
|
||||
render: (c) => {
|
||||
if (!c.pros) return <span className="text-gray-300">—</span>;
|
||||
const items = c.pros.split("\n").filter((s) => s.trim() !== "");
|
||||
if (items.length === 0) return <span className="text-gray-300">—</span>;
|
||||
return (
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
{items.map((item, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: stable list of static items
|
||||
<li key={i} className="text-xs text-gray-700">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "cons",
|
||||
label: "Cons",
|
||||
render: (c) => {
|
||||
if (!c.cons) return <span className="text-gray-300">—</span>;
|
||||
const items = c.cons.split("\n").filter((s) => s.trim() !== "");
|
||||
if (items.length === 0) return <span className="text-gray-300">—</span>;
|
||||
return (
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
{items.map((item, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: stable list of static items
|
||||
<li key={i} className="text-xs text-gray-700">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const tableMinWidth = Math.max(400, candidates.length * 180);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-100">
|
||||
<table
|
||||
className="border-collapse text-sm w-full"
|
||||
style={{ minWidth: `${tableMinWidth}px` }}
|
||||
>
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100">
|
||||
{/* Sticky empty corner cell */}
|
||||
<th className="sticky left-0 z-10 bg-white px-4 py-3 text-left text-xs font-medium text-gray-700 w-28" />
|
||||
{candidates.map((candidate) => {
|
||||
const isWinner = candidate.id === resolvedCandidateId;
|
||||
return (
|
||||
<th
|
||||
key={candidate.id}
|
||||
className={`px-4 py-3 text-left text-xs font-medium min-w-[160px] ${
|
||||
isWinner ? "bg-amber-50 text-amber-800" : "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{isWinner && (
|
||||
<LucideIcon
|
||||
name="trophy"
|
||||
size={12}
|
||||
className="text-amber-600"
|
||||
/>
|
||||
)}
|
||||
{candidate.name}
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ATTRIBUTE_ROWS.map((row) => (
|
||||
<tr key={row.key} className="border-b border-gray-50">
|
||||
{/* Sticky label cell */}
|
||||
<td className="sticky left-0 z-10 bg-white px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wide w-28">
|
||||
{row.label}
|
||||
</td>
|
||||
{candidates.map((candidate, index) => {
|
||||
const isWinner = candidate.id === resolvedCandidateId;
|
||||
const extraClass = row.cellClass
|
||||
? row.cellClass(candidate)
|
||||
: isWinner
|
||||
? "bg-amber-50/50"
|
||||
: "";
|
||||
return (
|
||||
<td
|
||||
key={candidate.id}
|
||||
className={`px-4 py-3 min-w-[160px] ${extraClass}`}
|
||||
>
|
||||
{row.render(candidate, index)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
import { useUIStore } from "../stores/uiStore";
|
||||
import { ClassificationBadge } from "./ClassificationBadge";
|
||||
|
||||
interface ItemCardProps {
|
||||
id: number;
|
||||
@@ -13,6 +15,8 @@ interface ItemCardProps {
|
||||
imageFilename: string | null;
|
||||
productUrl?: string | null;
|
||||
onRemove?: () => void;
|
||||
classification?: string;
|
||||
onClassificationCycle?: () => void;
|
||||
}
|
||||
|
||||
export function ItemCard({
|
||||
@@ -25,8 +29,11 @@ export function ItemCard({
|
||||
imageFilename,
|
||||
productUrl,
|
||||
onRemove,
|
||||
classification,
|
||||
onClassificationCycle,
|
||||
}: ItemCardProps) {
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
const openEditPanel = useUIStore((s) => s.openEditPanel);
|
||||
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
||||
|
||||
@@ -129,7 +136,7 @@ export function ItemCard({
|
||||
)}
|
||||
{priceCents != null && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
|
||||
{formatPrice(priceCents)}
|
||||
{formatPrice(priceCents, currency)}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
||||
@@ -140,6 +147,12 @@ export function ItemCard({
|
||||
/>{" "}
|
||||
{categoryName}
|
||||
</span>
|
||||
{classification && onClassificationCycle && (
|
||||
<ClassificationBadge
|
||||
classification={classification}
|
||||
onCycle={onClassificationCycle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { useItems } from "../hooks/useItems";
|
||||
import { useSyncSetupItems } from "../hooks/useSetups";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
@@ -22,6 +23,7 @@ export function ItemPicker({
|
||||
const { data: items } = useItems();
|
||||
const syncItems = useSyncSetupItems(setupId);
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// Reset selected IDs when panel opens
|
||||
@@ -121,7 +123,7 @@ export function ItemPicker({
|
||||
item.priceCents != null &&
|
||||
" · "}
|
||||
{item.priceCents != null &&
|
||||
formatPrice(item.priceCents)}
|
||||
formatPrice(item.priceCents, currency)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||
|
||||
@@ -18,6 +19,7 @@ export function SetupCard({
|
||||
totalCost,
|
||||
}: SetupCardProps) {
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
return (
|
||||
<Link
|
||||
to="/setups/$setupId"
|
||||
@@ -35,7 +37,7 @@ export function SetupCard({
|
||||
{formatWeight(totalWeight, unit)}
|
||||
</span>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
|
||||
{formatPrice(totalCost)}
|
||||
{formatPrice(totalCost, currency)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { formatPrice } from "../lib/formatters";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
|
||||
@@ -22,10 +23,11 @@ function formatDate(iso: string): string {
|
||||
function formatPriceRange(
|
||||
min: number | null,
|
||||
max: number | null,
|
||||
currency: Parameters<typeof formatPrice>[1],
|
||||
): string | null {
|
||||
if (min == null && max == null) return null;
|
||||
if (min === max) return formatPrice(min);
|
||||
return `${formatPrice(min)} - ${formatPrice(max)}`;
|
||||
if (min === max) return formatPrice(min, currency);
|
||||
return `${formatPrice(min, currency)} - ${formatPrice(max, currency)}`;
|
||||
}
|
||||
|
||||
export function ThreadCard({
|
||||
@@ -40,9 +42,10 @@ export function ThreadCard({
|
||||
categoryIcon,
|
||||
}: ThreadCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const currency = useCurrency();
|
||||
|
||||
const isResolved = status === "resolved";
|
||||
const priceRange = formatPriceRange(minPriceCents, maxPriceCents);
|
||||
const priceRange = formatPriceRange(minPriceCents, maxPriceCents, currency);
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -10,24 +10,25 @@ import {
|
||||
import type { SetupItemWithCategory } from "../hooks/useSetups";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import { formatWeight, type WeightUnit } from "../lib/formatters";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
|
||||
const CATEGORY_COLORS = [
|
||||
"#6366f1",
|
||||
"#f59e0b",
|
||||
"#10b981",
|
||||
"#ef4444",
|
||||
"#8b5cf6",
|
||||
"#06b6d4",
|
||||
"#f97316",
|
||||
"#ec4899",
|
||||
"#14b8a6",
|
||||
"#84cc16",
|
||||
"#374151",
|
||||
"#4b5563",
|
||||
"#6b7280",
|
||||
"#7f8a94",
|
||||
"#9ca3af",
|
||||
"#b0b7bf",
|
||||
"#c4c9cf",
|
||||
"#d1d5db",
|
||||
"#dfe2e6",
|
||||
"#e5e7eb",
|
||||
];
|
||||
|
||||
const CLASSIFICATION_COLORS: Record<string, string> = {
|
||||
base: "#6366f1",
|
||||
worn: "#f59e0b",
|
||||
consumable: "#10b981",
|
||||
base: "#6b7280",
|
||||
worn: "#9ca3af",
|
||||
consumable: "#d1d5db",
|
||||
};
|
||||
|
||||
const CLASSIFICATION_LABELS: Record<string, string> = {
|
||||
@@ -109,29 +110,34 @@ function CustomTooltip({
|
||||
);
|
||||
}
|
||||
|
||||
function SubtotalColumn({
|
||||
function LegendRow({
|
||||
color,
|
||||
label,
|
||||
weight,
|
||||
unit,
|
||||
color,
|
||||
percent,
|
||||
}: {
|
||||
color: string;
|
||||
label: string;
|
||||
weight: number;
|
||||
unit: WeightUnit;
|
||||
color?: string;
|
||||
percent?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{color && (
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-gray-500">{label}</span>
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="text-sm text-gray-600 flex-1">{label}</span>
|
||||
<span className="text-sm font-semibold text-gray-900 tabular-nums">
|
||||
{formatWeight(weight, unit)}
|
||||
</span>
|
||||
{percent != null && (
|
||||
<span className="text-xs text-gray-400 w-10 text-right tabular-nums">
|
||||
{(percent * 100).toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -237,27 +243,39 @@ export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Weight subtotals columns */}
|
||||
<div className="flex-1 grid grid-cols-4 gap-4">
|
||||
<SubtotalColumn
|
||||
label="Base"
|
||||
{/* Weight legend */}
|
||||
<div className="flex-1 flex flex-col justify-center min-w-0">
|
||||
<LegendRow
|
||||
color="#6b7280"
|
||||
label="Base Weight"
|
||||
weight={baseWeight}
|
||||
unit={unit}
|
||||
color="#6366f1"
|
||||
percent={totalWeight > 0 ? baseWeight / totalWeight : undefined}
|
||||
/>
|
||||
<SubtotalColumn
|
||||
<LegendRow
|
||||
color="#9ca3af"
|
||||
label="Worn"
|
||||
weight={wornWeight}
|
||||
unit={unit}
|
||||
color="#f59e0b"
|
||||
percent={totalWeight > 0 ? wornWeight / totalWeight : undefined}
|
||||
/>
|
||||
<SubtotalColumn
|
||||
<LegendRow
|
||||
color="#d1d5db"
|
||||
label="Consumable"
|
||||
weight={consumableWeight}
|
||||
unit={unit}
|
||||
color="#10b981"
|
||||
percent={totalWeight > 0 ? consumableWeight / totalWeight : undefined}
|
||||
/>
|
||||
<SubtotalColumn label="Total" weight={totalWeight} unit={unit} />
|
||||
<div className="border-t border-gray-200 mt-1.5 pt-1.5">
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<LucideIcon name="sigma" size={10} className="text-gray-400 shrink-0 ml-0.5" />
|
||||
<span className="text-sm font-medium text-gray-700 flex-1">Total</span>
|
||||
<span className="text-sm font-bold text-gray-900 tabular-nums">
|
||||
{formatWeight(totalWeight, unit)}
|
||||
</span>
|
||||
<span className="w-10" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { CreateCandidate, UpdateCandidate } from "../../shared/types";
|
||||
import { apiDelete, apiPost, apiPut } from "../lib/api";
|
||||
import { apiDelete, apiPatch, apiPost, apiPut } from "../lib/api";
|
||||
|
||||
interface CandidateResponse {
|
||||
id: number;
|
||||
@@ -13,6 +13,8 @@ interface CandidateResponse {
|
||||
productUrl: string | null;
|
||||
imageFilename: string | null;
|
||||
status: "researching" | "ordered" | "arrived";
|
||||
pros: string | null;
|
||||
cons: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -60,3 +62,17 @@ export function useDeleteCandidate(threadId: number) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
12
src/client/hooks/useCurrency.ts
Normal file
12
src/client/hooks/useCurrency.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Currency } from "../lib/formatters";
|
||||
import { useSetting } from "./useSettings";
|
||||
|
||||
const VALID_CURRENCIES: Currency[] = ["USD", "EUR", "GBP", "JPY", "CAD", "AUD"];
|
||||
|
||||
export function useCurrency(): Currency {
|
||||
const { data } = useSetting("currency");
|
||||
if (data && VALID_CURRENCIES.includes(data as Currency)) {
|
||||
return data as Currency;
|
||||
}
|
||||
return "USD";
|
||||
}
|
||||
@@ -27,6 +27,8 @@ interface CandidateWithCategory {
|
||||
productUrl: string | null;
|
||||
imageFilename: string | null;
|
||||
status: "researching" | "ordered" | "arrived";
|
||||
pros: string | null;
|
||||
cons: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
categoryName: string;
|
||||
|
||||
@@ -21,7 +21,25 @@ export function formatWeight(
|
||||
}
|
||||
}
|
||||
|
||||
export function formatPrice(cents: number | null | undefined): string {
|
||||
export type Currency = "USD" | "EUR" | "GBP" | "JPY" | "CAD" | "AUD";
|
||||
|
||||
const CURRENCY_SYMBOLS: Record<Currency, string> = {
|
||||
USD: "$",
|
||||
EUR: "€",
|
||||
GBP: "£",
|
||||
JPY: "¥",
|
||||
CAD: "CA$",
|
||||
AUD: "A$",
|
||||
};
|
||||
|
||||
export function formatPrice(
|
||||
cents: number | null | undefined,
|
||||
currency: Currency = "USD",
|
||||
): string {
|
||||
if (cents == null) return "--";
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
const symbol = CURRENCY_SYMBOLS[currency];
|
||||
if (currency === "JPY") {
|
||||
return `${symbol}${Math.round(cents / 100)}`;
|
||||
}
|
||||
return `${symbol}${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { icons } from "lucide-react";
|
||||
import type React from "react";
|
||||
|
||||
// --- Emoji to Lucide icon mapping (for migration/fallback) ---
|
||||
|
||||
@@ -232,18 +233,20 @@ interface LucideIconProps {
|
||||
name: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function LucideIcon({
|
||||
name,
|
||||
size = 20,
|
||||
className = "",
|
||||
style,
|
||||
}: LucideIconProps) {
|
||||
const pascalName = toPascalCase(name);
|
||||
const IconComponent = icons[pascalName as keyof typeof icons];
|
||||
if (!IconComponent) {
|
||||
const FallbackIcon = icons.Package;
|
||||
return <FallbackIcon size={size} className={className} />;
|
||||
return <FallbackIcon size={size} className={className} style={style} />;
|
||||
}
|
||||
return <IconComponent size={size} className={className} />;
|
||||
return <IconComponent size={size} className={className} style={style} />;
|
||||
}
|
||||
|
||||
@@ -9,11 +9,17 @@
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as SettingsRouteImport } from './routes/settings'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as CollectionIndexRouteImport } from './routes/collection/index'
|
||||
import { Route as ThreadsThreadIdRouteImport } from './routes/threads/$threadId'
|
||||
import { Route as SetupsSetupIdRouteImport } from './routes/setups/$setupId'
|
||||
|
||||
const SettingsRoute = SettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
@@ -37,12 +43,14 @@ const SetupsSetupIdRoute = SetupsSetupIdRouteImport.update({
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
||||
'/collection/': typeof CollectionIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
||||
'/collection': typeof CollectionIndexRoute
|
||||
@@ -50,18 +58,30 @@ export interface FileRoutesByTo {
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
||||
'/collection/': typeof CollectionIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/setups/$setupId' | '/threads/$threadId' | '/collection/'
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/settings'
|
||||
| '/setups/$setupId'
|
||||
| '/threads/$threadId'
|
||||
| '/collection/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/setups/$setupId' | '/threads/$threadId' | '/collection'
|
||||
to:
|
||||
| '/'
|
||||
| '/settings'
|
||||
| '/setups/$setupId'
|
||||
| '/threads/$threadId'
|
||||
| '/collection'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/settings'
|
||||
| '/setups/$setupId'
|
||||
| '/threads/$threadId'
|
||||
| '/collection/'
|
||||
@@ -69,6 +89,7 @@ export interface FileRouteTypes {
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
SettingsRoute: typeof SettingsRoute
|
||||
SetupsSetupIdRoute: typeof SetupsSetupIdRoute
|
||||
ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute
|
||||
CollectionIndexRoute: typeof CollectionIndexRoute
|
||||
@@ -76,6 +97,13 @@ export interface RootRouteChildren {
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/settings': {
|
||||
id: '/settings'
|
||||
path: '/settings'
|
||||
fullPath: '/settings'
|
||||
preLoaderRoute: typeof SettingsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
@@ -109,6 +137,7 @@ declare module '@tanstack/react-router' {
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
SettingsRoute: SettingsRoute,
|
||||
SetupsSetupIdRoute: SetupsSetupIdRoute,
|
||||
ThreadsThreadIdRoute: ThreadsThreadIdRoute,
|
||||
CollectionIndexRoute: CollectionIndexRoute,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { CategoryFilterDropdown } from "../../components/CategoryFilterDropdown";
|
||||
import { CategoryHeader } from "../../components/CategoryHeader";
|
||||
@@ -7,12 +8,14 @@ import { CreateThreadModal } from "../../components/CreateThreadModal";
|
||||
import { ItemCard } from "../../components/ItemCard";
|
||||
import { SetupCard } from "../../components/SetupCard";
|
||||
import { ThreadCard } from "../../components/ThreadCard";
|
||||
import { CollectionTabs } from "../../components/ThreadTabs";
|
||||
import { useCategories } from "../../hooks/useCategories";
|
||||
import { useItems } from "../../hooks/useItems";
|
||||
import { useCreateSetup, useSetups } from "../../hooks/useSetups";
|
||||
import { useThreads } from "../../hooks/useThreads";
|
||||
import { useTotals } from "../../hooks/useTotals";
|
||||
import { useCurrency } from "../../hooks/useCurrency";
|
||||
import { useWeightUnit } from "../../hooks/useWeightUnit";
|
||||
import { formatPrice, formatWeight } from "../../lib/formatters";
|
||||
import { LucideIcon } from "../../lib/iconData";
|
||||
import { useUIStore } from "../../stores/uiStore";
|
||||
|
||||
@@ -25,26 +28,43 @@ export const Route = createFileRoute("/collection/")({
|
||||
component: CollectionPage,
|
||||
});
|
||||
|
||||
const TAB_ORDER = ["gear", "planning", "setups"] as const;
|
||||
|
||||
const slideVariants = {
|
||||
enter: (dir: number) => ({ x: `${dir * 15}%`, opacity: 0 }),
|
||||
center: { x: 0, opacity: 1 },
|
||||
exit: (dir: number) => ({ x: `${dir * -15}%`, opacity: 0 }),
|
||||
};
|
||||
|
||||
function CollectionPage() {
|
||||
const { tab } = Route.useSearch();
|
||||
const navigate = useNavigate();
|
||||
const prevTab = useRef(tab);
|
||||
|
||||
function handleTabChange(newTab: "gear" | "planning" | "setups") {
|
||||
navigate({ to: "/collection", search: { tab: newTab } });
|
||||
}
|
||||
const direction =
|
||||
TAB_ORDER.indexOf(tab) >= TAB_ORDER.indexOf(prevTab.current) ? 1 : -1;
|
||||
prevTab.current = tab;
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<CollectionTabs active={tab} onChange={handleTabChange} />
|
||||
<div className="mt-6">
|
||||
{tab === "gear" ? (
|
||||
<CollectionView />
|
||||
) : tab === "planning" ? (
|
||||
<PlanningView />
|
||||
) : (
|
||||
<SetupsView />
|
||||
)}
|
||||
</div>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 overflow-x-hidden">
|
||||
<AnimatePresence mode="wait" initial={false} custom={direction}>
|
||||
<motion.div
|
||||
key={tab}
|
||||
custom={direction}
|
||||
variants={slideVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.12, ease: "easeInOut" }}
|
||||
>
|
||||
{tab === "gear" ? (
|
||||
<CollectionView />
|
||||
) : tab === "planning" ? (
|
||||
<PlanningView />
|
||||
) : (
|
||||
<SetupsView />
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +73,8 @@ function CollectionView() {
|
||||
const { data: items, isLoading: itemsLoading } = useItems();
|
||||
const { data: totals } = useTotals();
|
||||
const { data: categories } = useCategories();
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
const openAddPanel = useUIStore((s) => s.openAddPanel);
|
||||
|
||||
const [searchText, setSearchText] = useState("");
|
||||
@@ -169,8 +191,41 @@ function CollectionView() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Collection stats card */}
|
||||
{totals?.global && (
|
||||
<div className="bg-white rounded-xl border border-gray-100 p-5 mb-6">
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<LucideIcon name="layers" size={14} className="text-gray-400" />
|
||||
<span className="text-xs text-gray-500">Items</span>
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{totals.global.itemCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<LucideIcon name="weight" size={14} className="text-gray-400" />
|
||||
<span className="text-xs text-gray-500">Total Weight</span>
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{formatWeight(totals.global.totalWeight, unit)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<LucideIcon
|
||||
name="credit-card"
|
||||
size={14}
|
||||
className="text-gray-400"
|
||||
/>
|
||||
<span className="text-xs text-gray-500">Total Spent</span>
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{formatPrice(totals.global.totalCost, currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search/filter toolbar */}
|
||||
<div className="sticky top-0 z-10 bg-white/95 backdrop-blur-sm border-b border-gray-100 -mx-4 px-4 py-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 mb-6">
|
||||
<div className="sticky top-0 z-10 bg-gray-50/95 backdrop-blur-sm border-b border-gray-100 -mx-4 px-4 py-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 mb-6">
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
@@ -219,9 +274,7 @@ function CollectionView() {
|
||||
{hasActiveFilters ? (
|
||||
filteredItems.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-sm text-gray-500">
|
||||
No items match your search
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">No items match your search</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
@@ -516,21 +569,46 @@ function SetupsView() {
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && (!setups || setups.length === 0) && (
|
||||
<div className="py-16 text-center">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="mb-4">
|
||||
<LucideIcon
|
||||
name="tent"
|
||||
size={48}
|
||||
className="text-gray-400 mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
No setups yet
|
||||
<div className="py-16">
|
||||
<div className="max-w-lg mx-auto text-center">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-8">
|
||||
Build your perfect loadout
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Create one to plan your loadout.
|
||||
</p>
|
||||
<div className="space-y-6 text-left mb-10">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Create a setup</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Name your loadout for a specific trip or activity
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Add items</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Pick gear from your collection to include in the setup
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Track weight</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
See weight breakdown and optimize your pack
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSetups } from "../hooks/useSetups";
|
||||
import { useThreads } from "../hooks/useThreads";
|
||||
import { useTotals } from "../hooks/useTotals";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
@@ -15,6 +16,7 @@ function DashboardPage() {
|
||||
const { data: threads } = useThreads(false);
|
||||
const { data: setups } = useSetups();
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
|
||||
const global = totals?.global;
|
||||
const activeThreadCount = threads?.length ?? 0;
|
||||
@@ -33,7 +35,7 @@ function DashboardPage() {
|
||||
label: "Weight",
|
||||
value: formatWeight(global?.totalWeight ?? null, unit),
|
||||
},
|
||||
{ label: "Cost", value: formatPrice(global?.totalCost ?? null) },
|
||||
{ label: "Cost", value: formatPrice(global?.totalCost ?? null, currency) },
|
||||
]}
|
||||
emptyText="Get started"
|
||||
/>
|
||||
|
||||
104
src/client/routes/settings.tsx
Normal file
104
src/client/routes/settings.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { useUpdateSetting } from "../hooks/useSettings";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import type { Currency, WeightUnit } from "../lib/formatters";
|
||||
|
||||
const UNITS: WeightUnit[] = ["g", "oz", "lb", "kg"];
|
||||
const CURRENCIES: { value: Currency; label: string }[] = [
|
||||
{ value: "USD", label: "$" },
|
||||
{ value: "EUR", label: "€" },
|
||||
{ value: "GBP", label: "£" },
|
||||
{ value: "JPY", label: "¥" },
|
||||
{ value: "CAD", label: "CA$" },
|
||||
{ value: "AUD", label: "A$" },
|
||||
];
|
||||
|
||||
export const Route = createFileRoute("/settings")({
|
||||
component: SettingsPage,
|
||||
});
|
||||
|
||||
function SettingsPage() {
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
const updateSetting = useUpdateSetting();
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block"
|
||||
>
|
||||
← Back
|
||||
</Link>
|
||||
<h1 className="text-xl font-semibold text-gray-900">Settings</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900">Weight Unit</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Choose the unit used to display weights across the app
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-gray-100 rounded-full px-1 py-0.5">
|
||||
{UNITS.map((u) => (
|
||||
<button
|
||||
key={u}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
updateSetting.mutate({
|
||||
key: "weightUnit",
|
||||
value: u,
|
||||
})
|
||||
}
|
||||
className={`px-2.5 py-1 text-xs rounded-full transition-colors ${
|
||||
unit === u
|
||||
? "bg-white text-gray-700 shadow-sm font-medium"
|
||||
: "text-gray-400 hover:text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{u}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-100" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900">Currency</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Changes the currency symbol displayed. This does not convert
|
||||
values.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-gray-100 rounded-full px-1 py-0.5">
|
||||
{CURRENCIES.map((c) => (
|
||||
<button
|
||||
key={c.value}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
updateSetting.mutate({
|
||||
key: "currency",
|
||||
value: c.value,
|
||||
})
|
||||
}
|
||||
className={`px-2.5 py-1 text-xs rounded-full transition-colors ${
|
||||
currency === c.value
|
||||
? "bg-white text-gray-700 shadow-sm font-medium"
|
||||
: "text-gray-400 hover:text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { CategoryHeader } from "../../components/CategoryHeader";
|
||||
import { ClassificationBadge } from "../../components/ClassificationBadge";
|
||||
import { ItemCard } from "../../components/ItemCard";
|
||||
import { ItemPicker } from "../../components/ItemPicker";
|
||||
import { WeightSummaryCard } from "../../components/WeightSummaryCard";
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
useSetup,
|
||||
useUpdateItemClassification,
|
||||
} from "../../hooks/useSetups";
|
||||
import { useCurrency } from "../../hooks/useCurrency";
|
||||
import { useWeightUnit } from "../../hooks/useWeightUnit";
|
||||
import { formatPrice, formatWeight } from "../../lib/formatters";
|
||||
import { LucideIcon } from "../../lib/iconData";
|
||||
@@ -22,6 +22,7 @@ export const Route = createFileRoute("/setups/$setupId")({
|
||||
function SetupDetailPage() {
|
||||
const { setupId } = Route.useParams();
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
const navigate = useNavigate();
|
||||
const numericId = Number(setupId);
|
||||
const { data: setup, isLoading } = useSetup(numericId);
|
||||
@@ -107,9 +108,18 @@ function SetupDetailPage() {
|
||||
{/* Setup-specific sticky bar */}
|
||||
<div className="sticky top-14 z-[9] bg-gray-50 border-b border-gray-100 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-12">
|
||||
<h2 className="text-base font-semibold text-gray-900 truncate">
|
||||
{setup.name}
|
||||
</h2>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Link
|
||||
to="/collection"
|
||||
search={{ tab: "setups" }}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 shrink-0"
|
||||
>
|
||||
←
|
||||
</Link>
|
||||
<h2 className="text-base font-semibold text-gray-900 truncate">
|
||||
{setup.name}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span>
|
||||
<span className="font-medium text-gray-700">{itemCount}</span>{" "}
|
||||
@@ -123,7 +133,7 @@ function SetupDetailPage() {
|
||||
</span>
|
||||
<span>
|
||||
<span className="font-medium text-gray-700">
|
||||
{formatPrice(totalCost)}
|
||||
{formatPrice(totalCost, currency)}
|
||||
</span>{" "}
|
||||
cost
|
||||
</span>
|
||||
@@ -219,32 +229,27 @@ function SetupDetailPage() {
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{categoryItems.map((item) => (
|
||||
<div key={item.id}>
|
||||
<ItemCard
|
||||
id={item.id}
|
||||
name={item.name}
|
||||
weightGrams={item.weightGrams}
|
||||
priceCents={item.priceCents}
|
||||
categoryName={categoryName}
|
||||
categoryIcon={categoryIcon}
|
||||
imageFilename={item.imageFilename}
|
||||
productUrl={item.productUrl}
|
||||
onRemove={() => removeItem.mutate(item.id)}
|
||||
/>
|
||||
<div className="px-4 pb-3 -mt-1">
|
||||
<ClassificationBadge
|
||||
classification={item.classification}
|
||||
onCycle={() =>
|
||||
updateClassification.mutate({
|
||||
itemId: item.id,
|
||||
classification: nextClassification(
|
||||
item.classification,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ItemCard
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
name={item.name}
|
||||
weightGrams={item.weightGrams}
|
||||
priceCents={item.priceCents}
|
||||
categoryName={categoryName}
|
||||
categoryIcon={categoryIcon}
|
||||
imageFilename={item.imageFilename}
|
||||
productUrl={item.productUrl}
|
||||
onRemove={() => removeItem.mutate(item.id)}
|
||||
classification={item.classification}
|
||||
onClassificationCycle={() =>
|
||||
updateClassification.mutate({
|
||||
itemId: item.id,
|
||||
classification: nextClassification(
|
||||
item.classification,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { Reorder } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CandidateCard } from "../../components/CandidateCard";
|
||||
import { useUpdateCandidate } from "../../hooks/useCandidates";
|
||||
import { CandidateListItem } from "../../components/CandidateListItem";
|
||||
import { ComparisonTable } from "../../components/ComparisonTable";
|
||||
import {
|
||||
useReorderCandidates,
|
||||
useUpdateCandidate,
|
||||
} from "../../hooks/useCandidates";
|
||||
import { useThread } from "../../hooks/useThreads";
|
||||
import { LucideIcon } from "../../lib/iconData";
|
||||
import { useUIStore } from "../../stores/uiStore";
|
||||
@@ -14,7 +21,21 @@ function ThreadDetailPage() {
|
||||
const threadId = Number(threadIdParam);
|
||||
const { data: thread, isLoading, isError } = useThread(threadId);
|
||||
const openCandidateAddPanel = useUIStore((s) => s.openCandidateAddPanel);
|
||||
const candidateViewMode = useUIStore((s) => s.candidateViewMode);
|
||||
const setCandidateViewMode = useUIStore((s) => s.setCandidateViewMode);
|
||||
const updateCandidate = useUpdateCandidate(threadId);
|
||||
const reorderMutation = useReorderCandidates(threadId);
|
||||
|
||||
const [tempItems, setTempItems] =
|
||||
useState<typeof thread extends { candidates: infer C } ? C : never | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Clear tempItems when server data changes (biome-ignore: thread?.candidates is intentional dep)
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: thread?.candidates is the intended trigger
|
||||
useEffect(() => {
|
||||
setTempItems(null);
|
||||
}, [thread?.candidates]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -53,6 +74,16 @@ function ThreadDetailPage() {
|
||||
? thread.candidates.find((c) => c.id === thread.resolvedCandidateId)
|
||||
: null;
|
||||
|
||||
const displayItems = tempItems ?? thread.candidates;
|
||||
|
||||
function handleDragEnd() {
|
||||
if (!tempItems) return;
|
||||
reorderMutation.mutate(
|
||||
{ orderedIds: tempItems.map((c) => c.id) },
|
||||
{ onSettled: () => setTempItems(null) },
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
{/* Header */}
|
||||
@@ -88,9 +119,9 @@ function ThreadDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add candidate button */}
|
||||
{isActive && (
|
||||
<div className="mb-6">
|
||||
{/* Toolbar: Add candidate + view toggle */}
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
{isActive && candidateViewMode !== "compare" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCandidateAddPanel}
|
||||
@@ -111,10 +142,52 @@ function ThreadDetailPage() {
|
||||
</svg>
|
||||
Add Candidate
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{thread.candidates.length > 0 && (
|
||||
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCandidateViewMode("list")}
|
||||
className={`p-1.5 rounded-md transition-colors ${
|
||||
candidateViewMode === "list"
|
||||
? "bg-gray-200 text-gray-900"
|
||||
: "text-gray-400 hover:text-gray-600"
|
||||
}`}
|
||||
title="List view"
|
||||
>
|
||||
<LucideIcon name="layout-list" size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCandidateViewMode("grid")}
|
||||
className={`p-1.5 rounded-md transition-colors ${
|
||||
candidateViewMode === "grid"
|
||||
? "bg-gray-200 text-gray-900"
|
||||
: "text-gray-400 hover:text-gray-600"
|
||||
}`}
|
||||
title="Grid view"
|
||||
>
|
||||
<LucideIcon name="layout-grid" size={16} />
|
||||
</button>
|
||||
{thread.candidates.length >= 2 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCandidateViewMode("compare")}
|
||||
className={`p-1.5 rounded-md transition-colors ${
|
||||
candidateViewMode === "compare"
|
||||
? "bg-gray-200 text-gray-900"
|
||||
: "text-gray-400 hover:text-gray-600"
|
||||
}`}
|
||||
title="Compare view"
|
||||
>
|
||||
<LucideIcon name="columns-3" size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Candidate grid */}
|
||||
{/* Candidates */}
|
||||
{thread.candidates.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<div className="mb-3">
|
||||
@@ -131,9 +204,56 @@ function ThreadDetailPage() {
|
||||
Add your first candidate to start comparing.
|
||||
</p>
|
||||
</div>
|
||||
) : candidateViewMode === "compare" ? (
|
||||
<ComparisonTable
|
||||
candidates={displayItems}
|
||||
resolvedCandidateId={thread.resolvedCandidateId}
|
||||
/>
|
||||
) : candidateViewMode === "list" ? (
|
||||
isActive ? (
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={displayItems}
|
||||
onReorder={setTempItems}
|
||||
onPointerUp={handleDragEnd}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
{displayItems.map((candidate, index) => (
|
||||
<CandidateListItem
|
||||
key={candidate.id}
|
||||
candidate={candidate}
|
||||
rank={index + 1}
|
||||
isActive={isActive}
|
||||
onStatusChange={(newStatus) =>
|
||||
updateCandidate.mutate({
|
||||
candidateId: candidate.id,
|
||||
status: newStatus,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{displayItems.map((candidate, index) => (
|
||||
<CandidateListItem
|
||||
key={candidate.id}
|
||||
candidate={candidate}
|
||||
rank={index + 1}
|
||||
isActive={isActive}
|
||||
onStatusChange={(newStatus) =>
|
||||
updateCandidate.mutate({
|
||||
candidateId: candidate.id,
|
||||
status: newStatus,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{thread.candidates.map((candidate) => (
|
||||
{thread.candidates.map((candidate, index) => (
|
||||
<CandidateCard
|
||||
key={candidate.id}
|
||||
id={candidate.id}
|
||||
@@ -153,6 +273,9 @@ function ThreadDetailPage() {
|
||||
status: newStatus,
|
||||
})
|
||||
}
|
||||
pros={candidate.pros}
|
||||
cons={candidate.cons}
|
||||
rank={index + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -48,6 +48,10 @@ interface UIState {
|
||||
externalLinkUrl: string | null;
|
||||
openExternalLink: (url: string) => void;
|
||||
closeExternalLink: () => void;
|
||||
|
||||
// Candidate view mode
|
||||
candidateViewMode: "list" | "grid" | "compare";
|
||||
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>((set) => ({
|
||||
@@ -103,4 +107,8 @@ export const useUIStore = create<UIState>((set) => ({
|
||||
externalLinkUrl: null,
|
||||
openExternalLink: (url) => set({ externalLinkUrl: url }),
|
||||
closeExternalLink: () => set({ externalLinkUrl: null }),
|
||||
|
||||
// Candidate view mode
|
||||
candidateViewMode: "list",
|
||||
setCandidateViewMode: (mode) => set({ candidateViewMode: mode }),
|
||||
}));
|
||||
|
||||
@@ -59,6 +59,9 @@ export const threadCandidates = sqliteTable("thread_candidates", {
|
||||
productUrl: text("product_url"),
|
||||
imageFilename: text("image_filename"),
|
||||
status: text("status").notNull().default("researching"),
|
||||
pros: text("pros"),
|
||||
cons: text("cons"),
|
||||
sortOrder: real("sort_order").notNull().default(0),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Hono } from "hono";
|
||||
import {
|
||||
createCandidateSchema,
|
||||
createThreadSchema,
|
||||
reorderCandidatesSchema,
|
||||
resolveThreadSchema,
|
||||
updateCandidateSchema,
|
||||
updateThreadSchema,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
deleteThread,
|
||||
getAllThreads,
|
||||
getThreadWithCandidates,
|
||||
reorderCandidates,
|
||||
resolveThread,
|
||||
updateCandidate,
|
||||
updateThread,
|
||||
@@ -122,6 +124,21 @@ app.delete("/:threadId/candidates/:candidateId", async (c) => {
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// Candidate reorder
|
||||
|
||||
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 });
|
||||
},
|
||||
);
|
||||
|
||||
// Resolution
|
||||
|
||||
app.post("/:id/resolve", zValidator("json", resolveThreadSchema), (c) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { desc, eq, sql } from "drizzle-orm";
|
||||
import { asc, desc, eq, max, sql } from "drizzle-orm";
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
import {
|
||||
categories,
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
threadCandidates,
|
||||
threads,
|
||||
} from "../../db/schema.ts";
|
||||
import type { CreateCandidate, CreateThread } from "../../shared/types.ts";
|
||||
import type {
|
||||
CreateCandidate,
|
||||
CreateThread,
|
||||
ReorderCandidates,
|
||||
} from "../../shared/types.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
@@ -73,6 +77,8 @@ export function getThreadWithCandidates(db: Db = prodDb, threadId: number) {
|
||||
productUrl: threadCandidates.productUrl,
|
||||
imageFilename: threadCandidates.imageFilename,
|
||||
status: threadCandidates.status,
|
||||
pros: threadCandidates.pros,
|
||||
cons: threadCandidates.cons,
|
||||
createdAt: threadCandidates.createdAt,
|
||||
updatedAt: threadCandidates.updatedAt,
|
||||
categoryName: categories.name,
|
||||
@@ -81,6 +87,7 @@ export function getThreadWithCandidates(db: Db = prodDb, threadId: number) {
|
||||
.from(threadCandidates)
|
||||
.innerJoin(categories, eq(threadCandidates.categoryId, categories.id))
|
||||
.where(eq(threadCandidates.threadId, threadId))
|
||||
.orderBy(asc(threadCandidates.sortOrder))
|
||||
.all();
|
||||
|
||||
return { ...thread, candidates: candidateList };
|
||||
@@ -139,6 +146,14 @@ export function createCandidate(
|
||||
imageFilename?: string;
|
||||
},
|
||||
) {
|
||||
const maxRow = db
|
||||
.select({ maxOrder: max(threadCandidates.sortOrder) })
|
||||
.from(threadCandidates)
|
||||
.where(eq(threadCandidates.threadId, threadId))
|
||||
.get();
|
||||
|
||||
const nextSortOrder = (maxRow?.maxOrder ?? 0) + 1000;
|
||||
|
||||
return db
|
||||
.insert(threadCandidates)
|
||||
.values({
|
||||
@@ -151,6 +166,9 @@ export function createCandidate(
|
||||
productUrl: data.productUrl ?? null,
|
||||
imageFilename: data.imageFilename ?? null,
|
||||
status: data.status ?? "researching",
|
||||
pros: data.pros ?? null,
|
||||
cons: data.cons ?? null,
|
||||
sortOrder: nextSortOrder,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
@@ -168,6 +186,8 @@ export function updateCandidate(
|
||||
productUrl: string;
|
||||
imageFilename: string;
|
||||
status: "researching" | "ordered" | "arrived";
|
||||
pros: string;
|
||||
cons: string;
|
||||
}>,
|
||||
) {
|
||||
const existing = db
|
||||
@@ -197,6 +217,35 @@ export function deleteCandidate(db: Db = prodDb, candidateId: number) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function reorderCandidates(
|
||||
db: Db = prodDb,
|
||||
threadId: number,
|
||||
orderedIds: ReorderCandidates["orderedIds"],
|
||||
): { success: boolean; error?: string } {
|
||||
return db.transaction((tx) => {
|
||||
const thread = tx
|
||||
.select()
|
||||
.from(threads)
|
||||
.where(eq(threads.id, threadId))
|
||||
.get();
|
||||
if (!thread) {
|
||||
return { success: false, error: "Thread not found" };
|
||||
}
|
||||
if (thread.status !== "active") {
|
||||
return { success: false, error: "Thread not active" };
|
||||
}
|
||||
|
||||
for (let i = 0; i < orderedIds.length; i++) {
|
||||
tx.update(threadCandidates)
|
||||
.set({ sortOrder: (i + 1) * 1000 })
|
||||
.where(eq(threadCandidates.id, orderedIds[i]))
|
||||
.run();
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveThread(
|
||||
db: Db = prodDb,
|
||||
threadId: number,
|
||||
|
||||
@@ -53,6 +53,8 @@ export const createCandidateSchema = z.object({
|
||||
productUrl: z.string().url().optional().or(z.literal("")),
|
||||
imageFilename: z.string().optional(),
|
||||
status: candidateStatusSchema.optional(),
|
||||
pros: z.string().optional(),
|
||||
cons: z.string().optional(),
|
||||
});
|
||||
|
||||
export const updateCandidateSchema = createCandidateSchema.partial();
|
||||
@@ -61,6 +63,10 @@ export const resolveThreadSchema = z.object({
|
||||
candidateId: z.number().int().positive(),
|
||||
});
|
||||
|
||||
export const reorderCandidatesSchema = z.object({
|
||||
orderedIds: z.array(z.number().int().positive()).min(1),
|
||||
});
|
||||
|
||||
// Setup schemas
|
||||
export const createSetupSchema = z.object({
|
||||
name: z.string().min(1, "Setup name is required"),
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
createItemSchema,
|
||||
createSetupSchema,
|
||||
createThreadSchema,
|
||||
reorderCandidatesSchema,
|
||||
resolveThreadSchema,
|
||||
syncSetupItemsSchema,
|
||||
updateCandidateSchema,
|
||||
@@ -33,6 +34,7 @@ export type UpdateThread = z.infer<typeof updateThreadSchema>;
|
||||
export type CreateCandidate = z.infer<typeof createCandidateSchema>;
|
||||
export type UpdateCandidate = z.infer<typeof updateCandidateSchema>;
|
||||
export type ResolveThread = z.infer<typeof resolveThreadSchema>;
|
||||
export type ReorderCandidates = z.infer<typeof reorderCandidatesSchema>;
|
||||
|
||||
// Setup types
|
||||
export type CreateSetup = z.infer<typeof createSetupSchema>;
|
||||
|
||||
@@ -55,6 +55,9 @@ export function createTestDb() {
|
||||
product_url TEXT,
|
||||
image_filename TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'researching',
|
||||
pros TEXT,
|
||||
cons TEXT,
|
||||
sort_order REAL NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
)
|
||||
|
||||
@@ -234,6 +234,119 @@ describe("Thread Routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/threads/:id/candidates/reorder", () => {
|
||||
it("with valid orderedIds returns 200 + { success: true }", async () => {
|
||||
const thread = await createThreadViaAPI(app, "Reorder Test");
|
||||
const c1 = await createCandidateViaAPI(app, thread.id, {
|
||||
name: "Candidate A",
|
||||
categoryId: 1,
|
||||
});
|
||||
const c2 = await createCandidateViaAPI(app, thread.id, {
|
||||
name: "Candidate B",
|
||||
categoryId: 1,
|
||||
});
|
||||
|
||||
const res = await app.request(
|
||||
`/api/threads/${thread.id}/candidates/reorder`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ orderedIds: [c2.id, c1.id] }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
|
||||
it("after PATCH reorder, GET thread returns candidates in the new order", async () => {
|
||||
const thread = await createThreadViaAPI(app, "Order Verify");
|
||||
const c1 = await createCandidateViaAPI(app, thread.id, {
|
||||
name: "First",
|
||||
categoryId: 1,
|
||||
});
|
||||
const c2 = await createCandidateViaAPI(app, thread.id, {
|
||||
name: "Second",
|
||||
categoryId: 1,
|
||||
});
|
||||
const c3 = await createCandidateViaAPI(app, thread.id, {
|
||||
name: "Third",
|
||||
categoryId: 1,
|
||||
});
|
||||
|
||||
// Reverse the order
|
||||
await app.request(`/api/threads/${thread.id}/candidates/reorder`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ orderedIds: [c3.id, c2.id, c1.id] }),
|
||||
});
|
||||
|
||||
const res = await app.request(`/api/threads/${thread.id}`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.candidates[0].id).toBe(c3.id);
|
||||
expect(body.candidates[1].id).toBe(c2.id);
|
||||
expect(body.candidates[2].id).toBe(c1.id);
|
||||
});
|
||||
|
||||
it("on a resolved thread returns 400", async () => {
|
||||
const thread = await createThreadViaAPI(app, "Resolved Thread");
|
||||
const candidate = await createCandidateViaAPI(app, thread.id, {
|
||||
name: "Winner",
|
||||
categoryId: 1,
|
||||
});
|
||||
|
||||
// Resolve the thread first
|
||||
await app.request(`/api/threads/${thread.id}/resolve`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ candidateId: candidate.id }),
|
||||
});
|
||||
|
||||
const res = await app.request(
|
||||
`/api/threads/${thread.id}/candidates/reorder`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ orderedIds: [candidate.id] }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("with invalid body (empty orderedIds) returns 400", async () => {
|
||||
const thread = await createThreadViaAPI(app, "Invalid Body");
|
||||
|
||||
const res = await app.request(
|
||||
`/api/threads/${thread.id}/candidates/reorder`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ orderedIds: [] }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("with missing orderedIds field returns 400", async () => {
|
||||
const thread = await createThreadViaAPI(app, "Missing Field");
|
||||
|
||||
const res = await app.request(
|
||||
`/api/threads/${thread.id}/candidates/reorder`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/threads/:id/resolve", () => {
|
||||
it("with valid candidateId returns 200 + created item", async () => {
|
||||
const thread = await createThreadViaAPI(app, "Tent Decision");
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
deleteThread,
|
||||
getAllThreads,
|
||||
getThreadWithCandidates,
|
||||
reorderCandidates,
|
||||
resolveThread,
|
||||
updateCandidate,
|
||||
updateThread,
|
||||
@@ -109,6 +110,21 @@ describe("Thread Service", () => {
|
||||
const result = getThreadWithCandidates(db, 9999);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("includes pros and cons on each candidate", () => {
|
||||
const thread = createThread(db, { name: "Tent Options", categoryId: 1 });
|
||||
createCandidate(db, thread.id, {
|
||||
name: "Tent A",
|
||||
categoryId: 1,
|
||||
pros: "Lightweight",
|
||||
cons: "Pricey",
|
||||
});
|
||||
|
||||
const result = getThreadWithCandidates(db, thread.id);
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.candidates[0].pros).toBe("Lightweight");
|
||||
expect(result?.candidates[0].cons).toBe("Pricey");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCandidate", () => {
|
||||
@@ -133,6 +149,30 @@ describe("Thread Service", () => {
|
||||
expect(candidate.notes).toBe("Ultralight 2-person");
|
||||
expect(candidate.productUrl).toBe("https://example.com/tent");
|
||||
});
|
||||
|
||||
it("stores and returns pros and cons", () => {
|
||||
const thread = createThread(db, { name: "Tent Options", categoryId: 1 });
|
||||
const candidate = createCandidate(db, thread.id, {
|
||||
name: "Tent A",
|
||||
categoryId: 1,
|
||||
pros: "Lightweight\nGood reviews",
|
||||
cons: "Expensive",
|
||||
});
|
||||
|
||||
expect(candidate.pros).toBe("Lightweight\nGood reviews");
|
||||
expect(candidate.cons).toBe("Expensive");
|
||||
});
|
||||
|
||||
it("returns null for pros and cons when not provided", () => {
|
||||
const thread = createThread(db, { name: "Tent Options", categoryId: 1 });
|
||||
const candidate = createCandidate(db, thread.id, {
|
||||
name: "Tent B",
|
||||
categoryId: 1,
|
||||
});
|
||||
|
||||
expect(candidate.pros).toBeNull();
|
||||
expect(candidate.cons).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateCandidate", () => {
|
||||
@@ -157,6 +197,31 @@ describe("Thread Service", () => {
|
||||
const result = updateCandidate(db, 9999, { name: "Ghost" });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("can set and clear pros and cons", () => {
|
||||
const thread = createThread(db, { name: "Test", categoryId: 1 });
|
||||
const candidate = createCandidate(db, thread.id, {
|
||||
name: "Original",
|
||||
categoryId: 1,
|
||||
});
|
||||
|
||||
// Set pros and cons
|
||||
const withPros = updateCandidate(db, candidate.id, {
|
||||
pros: "Lightweight",
|
||||
cons: "Expensive",
|
||||
});
|
||||
expect(withPros?.pros).toBe("Lightweight");
|
||||
expect(withPros?.cons).toBe("Expensive");
|
||||
|
||||
// Clear pros and cons by setting to empty string
|
||||
const cleared = updateCandidate(db, candidate.id, {
|
||||
pros: "",
|
||||
cons: "",
|
||||
});
|
||||
// Empty string stored as-is or null — either is acceptable
|
||||
expect(cleared?.pros == null || cleared?.pros === "").toBe(true);
|
||||
expect(cleared?.cons == null || cleared?.cons === "").toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteCandidate", () => {
|
||||
@@ -298,6 +363,95 @@ describe("Thread Service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sort_order ordering", () => {
|
||||
it("getThreadWithCandidates returns candidates ordered by sort_order ascending", () => {
|
||||
const thread = createThread(db, { name: "Order Test", categoryId: 1 });
|
||||
const c1 = createCandidate(db, thread.id, {
|
||||
name: "Candidate 1",
|
||||
categoryId: 1,
|
||||
});
|
||||
const c2 = createCandidate(db, thread.id, {
|
||||
name: "Candidate 2",
|
||||
categoryId: 1,
|
||||
});
|
||||
const c3 = createCandidate(db, thread.id, {
|
||||
name: "Candidate 3",
|
||||
categoryId: 1,
|
||||
});
|
||||
|
||||
// Manually set sort_orders out of creation order using reorderCandidates
|
||||
reorderCandidates(db, thread.id, [c3.id, c1.id, c2.id]);
|
||||
|
||||
const result = getThreadWithCandidates(db, thread.id);
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.candidates[0].id).toBe(c3.id);
|
||||
expect(result?.candidates[1].id).toBe(c1.id);
|
||||
expect(result?.candidates[2].id).toBe(c2.id);
|
||||
});
|
||||
|
||||
it("createCandidate assigns sort_order = max existing sort_order + 1000", () => {
|
||||
const thread = createThread(db, { name: "Append Test", categoryId: 1 });
|
||||
|
||||
// First candidate should get sort_order 1000
|
||||
const c1 = createCandidate(db, thread.id, {
|
||||
name: "First",
|
||||
categoryId: 1,
|
||||
});
|
||||
expect(c1.sortOrder).toBe(1000);
|
||||
|
||||
// Second candidate should get sort_order 2000
|
||||
const c2 = createCandidate(db, thread.id, {
|
||||
name: "Second",
|
||||
categoryId: 1,
|
||||
});
|
||||
expect(c2.sortOrder).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reorderCandidates", () => {
|
||||
it("reorderCandidates updates sort_order so querying returns candidates in new order", () => {
|
||||
const thread = createThread(db, { name: "Reorder Test", categoryId: 1 });
|
||||
const c1 = createCandidate(db, thread.id, {
|
||||
name: "Candidate 1",
|
||||
categoryId: 1,
|
||||
});
|
||||
const c2 = createCandidate(db, thread.id, {
|
||||
name: "Candidate 2",
|
||||
categoryId: 1,
|
||||
});
|
||||
const c3 = createCandidate(db, thread.id, {
|
||||
name: "Candidate 3",
|
||||
categoryId: 1,
|
||||
});
|
||||
|
||||
const result = reorderCandidates(db, thread.id, [c3.id, c1.id, c2.id]);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const fetched = getThreadWithCandidates(db, thread.id);
|
||||
expect(fetched?.candidates[0].id).toBe(c3.id);
|
||||
expect(fetched?.candidates[1].id).toBe(c1.id);
|
||||
expect(fetched?.candidates[2].id).toBe(c2.id);
|
||||
});
|
||||
|
||||
it("returns { success: false, error } when thread status is 'resolved'", () => {
|
||||
const thread = createThread(db, { name: "Resolved Thread", categoryId: 1 });
|
||||
const candidate = createCandidate(db, thread.id, {
|
||||
name: "Winner",
|
||||
categoryId: 1,
|
||||
});
|
||||
resolveThread(db, thread.id, candidate.id);
|
||||
|
||||
const result = reorderCandidates(db, thread.id, [candidate.id]);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns { success: false } when thread does not exist", () => {
|
||||
const result = reorderCandidates(db, 9999, [1, 2]);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveThread", () => {
|
||||
it("atomically creates collection item from candidate data and archives thread", () => {
|
||||
const thread = createThread(db, { name: "Tent Decision", categoryId: 1 });
|
||||
|
||||
Reference in New Issue
Block a user