33 KiB
Pitfalls Research
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 (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: dnd-kit + React Query Cache Produces Visible Flicker on Drop
What goes wrong:
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:
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:
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
useEffectsyncing (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
tempItemsononDragCancel)
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:
- Item visibly snaps back to original position for ~1 frame on drop
onDragEndcallsmutate()but uses no local state bridgesetQueryDatais the only state update on drag end
Phase to address:
Candidate ranking phase — the tempItems pattern must be designed before building the drag UI, not retrofitted after noticing the flicker.
Pitfall 2: Rank Storage Using Integer Offsets Requires Bulk Writes
What goes wrong:
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:
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:
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.
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:
sortOrdercolumn usesinteger()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: 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: Impact Preview Reads Stale Candidate Data
What goes wrong:
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: 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:
- 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 - replacedItemWeightwherereplacedItemWeightis taken from the currently loaded setup data. - Use
useQueryfor setup data with the setup selector in the same component that renders the comparison, so both data sources are reactive. - Handle null weight explicitly: show "-- (no weight data)" not "--g" for candidates without weights. Make the null state visually distinct from a zero delta.
- Do NOT make a server-side
/api/threads/:id/impact?setupId=:sidendpoint 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:
- 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: 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: Side-by-Side Comparison Breaks at Narrow Widths
What goes wrong: 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: 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:
- 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. - 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.
- Use a minimum column width (e.g.,
min-width: 200px) so the container scrolls horizontally before the column content becomes illegible. - Sticky first column for candidate names when scrolling horizontally, so the user always knows which column they are reading.
- Test at 768px viewport width before considering the feature done.
Warning signs:
- 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: 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: Pros/Cons Fields Stored as Free Text in Column, Not Structured
What goes wrong:
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:
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:
- 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.
- In the form, use a
<textarea>with a placeholder of "one item per line." Show a character count. - 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.
- Cap
prosandconsfield length at 500 characters in the Zod schema to prevent essay-length blobs. - The comparison view should truncate to the first 3 bullets when in compact comparison mode, with expand option.
Warning signs:
pros TEXTandcons TEXTadded 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: 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: Impact Preview Compares Against Wrong Setup Total When Item Would Be Replaced
What goes wrong:
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: "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:
- Support both modes: "add to setup" (+delta) and "replace item" (delta = candidate - replaced item). Make the mode selection explicit in the UI.
- 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").
- The replacement item selector should be a dropdown filtered to setup items in the same category, defaulting to the most likely match.
- If no setup is selected, show raw candidate weight rather than a delta — do not calculate a delta against zero.
Warning signs:
- 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.weightGramswith no baseline
Phase to address: 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: Schema Change Adds Columns Without Updating Test Helper
What goes wrong:
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: 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:
For every schema change in v1.3, update tests/helpers/db.ts in the same commit:
thread_candidates: addsort_order REAL DEFAULT 0,pros TEXT,cons TEXT- Run
bun testimmediately 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:
- Tests failing with
SqliteError: no such column - New service function works in the running app but throws in
bun test bun run db:pushwas run butbun testwas not run afterwardtests/helpers/db.tshas fewer columns thansrc/db/schema.ts
Phase to address: 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:
- 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. - Disable drag-to-reorder on resolved threads entirely — don't render the drag handles.
- 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.
- 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.statuscheck 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.
Technical Debt Patterns
Shortcuts that seem reasonable but create long-term problems.
| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|---|---|---|---|
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
Common mistakes when connecting new v1.3 features to existing v1.2 systems.
| Integration Point | Common Mistake | Correct Approach |
|---|---|---|
| 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
Patterns that work at small scale but fail as usage grows.
| Trap | Symptoms | Prevention | When It Breaks |
|---|---|---|---|
| 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
Domain-specific security issues beyond general web security.
| Mistake | Risk | Prevention |
|---|---|---|
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
Common user experience mistakes in this domain.
| Pitfall | User Impact | Better Approach |
|---|---|---|
| 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.
- 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
KeyboardSensormust be added toDndContext) - Drag-to-reorder: Often missing flicker fix — verify dropping a candidate does not briefly snap back to original position (requires
tempItemslocal state, not justsetQueryData) - 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()anduseCurrency(), 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
\ncharacters - Schema changes: Often missing test helper update — verify
tests/helpers/db.tsincludessort_order,pros, andconscolumns after the schema migration
Recovery Strategies
When pitfalls occur despite prevention, how to recover.
| Pitfall | Recovery Cost | Recovery Steps |
|---|---|---|
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
How roadmap phases should address these pitfalls.
| Pitfall | Prevention Phase | Verification |
|---|---|---|
| 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
- dnd-kit Discussion #1522: React Query + DnD flicker —
tempItemssolution for React Query cache flicker - dnd-kit Issue #921: Sorting not working with React Query — Root cause of the state lifecycle mismatch
- dnd-kit Sortable Docs: OptimisticSortingPlugin — New API for handling optimistic reorder
- TanStack Query: Optimistic Updates guide —
onMutate/onSettledrollback patterns - Fractional Indexing: Steveruiz.me — Why fractional keys beat integer reorder for databases
- Fractional Indexing SQLite library — Implementation reference for base62 lexicographic sort keys
- Baymard Institute: Comparison Tool Design — Sticky headers, horizontal scroll, minimum column width for product comparison UX
- NN/G: Comparison Tables — Avoid prose in comparison cells; use scannable structured values
- LogRocket: When comparison charts hurt UX — 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.3 — Research & Decision Tools (side-by-side comparison, impact preview, candidate ranking) Researched: 2026-03-16