docs: complete project research
This commit is contained in:
@@ -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*
|
||||
|
||||
Reference in New Issue
Block a user