docs: complete project research

This commit is contained in:
2026-03-16 11:39:16 +01:00
parent 1324018989
commit 79457053b3
5 changed files with 1273 additions and 889 deletions

View File

@@ -1,136 +1,202 @@
# Pitfalls Research
**Domain:** Gear management, collection tracking, purchase planning (single-user web app)
**Researched:** 2026-03-14
**Confidence:** HIGH (domain-specific patterns well-documented across gear community and inventory app space)
**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)
**Researched:** 2026-03-16
**Confidence:** HIGH (pitfalls derived from direct codebase analysis + domain-specific patterns from gear tracking community + React/SQLite ecosystem knowledge)
## Critical Pitfalls
### Pitfall 1: Unit Handling Treated as Display-Only
### Pitfall 1: Weight Unit Conversion Rounding Accumulation
**What goes wrong:**
Weight and price values are stored as bare numbers without unit metadata. The app assumes everything is grams or dollars, then breaks when users enter ounces, pounds, kilograms, or foreign currencies. Worse: calculations like "total setup weight" silently produce garbage when items have mixed units. A 200g tent and a 5lb sleeping bag get summed as 205.
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.
**Why it happens:**
In a single-user app it feels safe to skip unit handling -- "I'll just always use grams." But real product specs come in mixed units (manufacturers list in oz, g, kg, lb), and copy-pasting from product pages means mixed data creeps in immediately.
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.
**How to avoid:**
Store all weights in a canonical unit (grams) at write time. Accept input in any unit but convert on save. Store the original unit for display purposes but always compute on the canonical value. Build a simple conversion layer from day one -- it is 20 lines of code now vs. a data migration later.
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.
**Warning signs:**
- Weight field is a plain number input with no unit selector
- No conversion logic exists anywhere in the codebase
- Aggregation functions (total weight) do simple `SUM()` without unit awareness
- 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
**Phase to address:**
Phase 1 (Data model / Core CRUD) -- unit handling must be in the schema from the start. Retrofitting requires migrating every existing item.
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).
---
### Pitfall 2: Rigid Category Hierarchy Instead of Flexible Tagging
### Pitfall 2: Weight Classification Stored at Wrong Level
**What goes wrong:**
The app ships with a fixed category tree (Shelter > Tents > 1-Person Tents) that works for bikepacking but fails for sim racing gear, photography equipment, or any other hobby. Users cannot create categories, and items that span categories (a jacket that is both "clothing" and "rain gear") get awkwardly forced into one slot. The "generic enough for any hobby" goal from PROJECT.md dies on contact with a rigid hierarchy.
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.
**Why it happens:**
Hierarchical categories feel structured and "correct" during design. Flat tags feel messy. But hierarchies require knowing the domain upfront, and GearBox explicitly needs to support arbitrary hobbies.
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."
**How to avoid:**
Use a flat tag/label system as the primary organization mechanism. Users create their own tags ("bikepacking", "sleep-system", "cook-kit"). An item can have multiple tags. Optionally allow a single "category" field for broad grouping, but do not enforce hierarchy. Tags are the flexible axis; a single category field is the structured axis.
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)`
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`.
**Warning signs:**
- Schema has a `category_id` foreign key to a `categories` table with `parent_id`
- Seed data contains a pre-built category tree
- Adding a new hobby requires modifying the database
- `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
**Phase to address:**
Phase 1 (Data model) -- this is a schema-level decision. Changing from hierarchy to tags after data exists requires migration of every item's categorization.
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.
---
### Pitfall 3: Planning Thread State Machine Complexity Explosion
### Pitfall 3: Search/Filter Implemented Server-Side for a Client-Side Dataset
**What goes wrong:**
Thread items have statuses (researching, ordered, arrived) plus a thread-level resolution (pick winner, close thread, move to collection). Developers build these as independent fields without modeling the valid state transitions, leading to impossible states: an item marked "arrived" in a thread that was "cancelled," or a "winner" that was never "ordered." The UI then needs defensive checks everywhere, and bugs appear as ghost items in the collection.
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
**Why it happens:**
Status tracking looks simple -- it is just a string field. But the combination of item-level status + thread-level lifecycle + the "move winner to collection" side effect creates a state machine with many transitions, and without explicit modeling, invalid states are reachable.
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.
**How to avoid:**
Model the thread lifecycle as an explicit state machine with defined transitions. Document which item statuses are valid in each thread state. The "resolve thread" action should be a single transaction that: (1) validates the winner exists, (2) creates the collection item, (3) marks the thread as resolved, (4) updates the thread item status. Use a state diagram during design, not just field definitions.
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.
**Warning signs:**
- Thread status and item status are independent string/enum fields with no transition validation
- No transaction wrapping the "resolve thread + create collection item" flow
- UI shows impossible combinations (resolved thread with "researching" items)
- 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
**Phase to address:**
Phase 2 (Planning threads) -- design the state machine before writing any thread code. Do not add statuses incrementally.
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.
---
### Pitfall 4: Image Storage Strategy Causes Data Loss or Bloat
### Pitfall 4: Candidate Status Transition Without Validation
**What goes wrong:**
Two failure modes: (A) Images stored as file paths break when files are moved, deleted, or the app directory changes. Dangling references show broken image icons everywhere. (B) Images stored as BLOBs in SQLite bloat the database, slow down backups, and make the DB file unwieldy as the collection grows.
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).
**Why it happens:**
Image storage seems like a simple problem. File paths are the obvious approach but create a coupling between database records and filesystem state. BLOBs seem self-contained but do not scale with photo-heavy collections.
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.
**How to avoid:**
Store images in a dedicated directory within the app's data folder (e.g., `data/images/{item-id}/`). Store relative paths in the database (never absolute). Generate deterministic filenames from item ID + timestamp to avoid collisions. On item deletion, clean up the image directory. For thumbnails under 100KB, SQLite BLOBs are actually 35% faster than filesystem reads, so consider storing thumbnails as BLOBs while keeping full-size images on disk.
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)
**Warning signs:**
- Absolute file paths in the database
- No cleanup logic when items are deleted (orphaned images accumulate)
- Database file growing much larger than expected (images stored as BLOBs)
- No fallback/placeholder when an image file is missing
- 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
**Phase to address:**
Phase 1 (Core CRUD with item photos) -- image handling must be decided before any photos are stored. Migrating image storage strategy later requires moving files and updating every record.
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.
---
### Pitfall 5: Setup Composition Breaks on Collection Changes
### Pitfall 5: Weight Distribution Chart Diverges from Displayed Totals
**What goes wrong:**
A setup ("Summer Bikepacking") references items from the collection. When an item is deleted from the collection, updated, or replaced via a planning thread resolution, the setup silently breaks -- showing stale data, missing items, or incorrect totals. The user's carefully composed setup becomes untrustworthy.
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.
**Why it happens:**
Setups are modeled as a simple join table (setup_id, item_id) without considering what happens when the item side changes. The relationship is treated as static when it is actually dynamic.
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.
**How to avoid:**
Use foreign keys with explicit `ON DELETE` behavior (not CASCADE -- that silently removes setup entries). When an item is deleted, mark the setup-item link as "removed" and show a visual indicator in the setup view ("1 item no longer in collection"). When a planning thread resolves and replaces an item, offer to update setups that contained the old item. Setups should always recompute totals from live item data, never cache them.
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.
**Warning signs:**
- Setup totals are stored as columns rather than computed from item data
- No foreign key constraints between setups and items
- Deleting a collection item does not check if it belongs to any setup
- No UI indication when a setup references a missing item
- 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
**Phase to address:**
Phase 3 (Setups) -- but the foreign key design must be planned in Phase 1 when the items table is created. The item schema needs to anticipate setup references.
Phase 3 (Weight distribution visualization) -- but the single-source-of-truth pattern should be established in Phase 1 when refactoring formatters for unit selection.
---
### Pitfall 6: Comparison View That Does Not Actually Help Decisions
### Pitfall 6: Schema Migration Breaks Test Helper
**What goes wrong:**
The side-by-side comparison in planning threads shows raw data (weight: 450g, price: $120) without context. Users cannot see at a glance which candidate is lighter, cheaper, or how each compares to what they already own. The comparison becomes a formatted table, not a decision tool. Users go back to their spreadsheet because it was easier to add formulas.
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.
**Why it happens:**
Building a comparison view that displays data is easy. Building one that surfaces insights ("this is 30% lighter than your current tent but costs 2x more") requires computing deltas against the existing collection, which is a different feature than just showing two items side by side.
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.
**How to avoid:**
Design comparison views to show: (1) absolute values for each candidate, (2) deltas between candidates (highlighted: lighter/heavier, cheaper/more expensive), (3) delta against the current item being replaced from the collection. Use color coding or directional indicators (green down arrow for weight savings, red up arrow for cost increase). This is the core value proposition of GearBox -- do not ship a comparison that is worse than a spreadsheet.
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.
**Warning signs:**
- Comparison view is a static table with no computed differences
- No way to link a thread to "the item I'm replacing" from the collection
- Weight/cost impact on overall setup is not visible from the thread view
- 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
**Phase to address:**
Phase 2 (Planning threads) -- comparison is the heart of the thread feature. Build the delta computation alongside the basic thread CRUD, not as a follow-up.
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.
---
### Pitfall 7: Weight Unit Preference Stored Wrong, Applied Wrong
**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.
**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.
**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.
**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
**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.
---
@@ -140,21 +206,24 @@ Shortcuts that seem reasonable but create long-term problems.
| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|----------|-------------------|----------------|-----------------|
| Caching setup totals in a column | Faster reads, simpler queries | Stale data when items change, bugs when totals disagree with item sum | Never -- always compute from source items |
| Storing currency as float | Simple to implement | Floating point rounding errors in price totals (classic $0.01 bugs) | Never -- use integer cents or a decimal type |
| Skipping "replaced by" links in threads | Simpler thread resolution | Cannot track upgrade history, cannot auto-update setups | Only in earliest prototype, must add before thread resolution ships |
| Hardcoding unit labels | Faster initial development | Cannot support multiple hobbies with different unit conventions (e.g., ml for water bottles) | MVP only if unit conversion layer is planned for next phase |
| Single image per item | Simpler UI and storage | Gear often needs multiple angles, especially for condition tracking | Acceptable for v1 if schema supports multiple images (just limit UI to one) |
| 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) |
## Integration Gotchas
Common mistakes when connecting to external services.
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.
| Integration | Common Mistake | Correct Approach |
|-------------|----------------|------------------|
| Product link scraping | Attempting to auto-fetch product details from URLs, which breaks constantly as sites change layouts | Store the URL as a plain link. Do not scrape. Let users enter details manually. Scraping is a maintenance burden that exceeds its value for a single-user app. |
| Image URLs vs local storage | Hotlinking product images from retailer sites, which break when products are delisted | Always download and store images locally. External URLs rot within months. |
| Export/import formats | Building a custom JSON format that only GearBox understands | Support CSV import/export as the universal fallback. Users are migrating from spreadsheets -- CSV is their native format. |
| 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. |
## Performance Traps
@@ -162,10 +231,11 @@ Patterns that work at small scale but fail as usage grows.
| Trap | Symptoms | Prevention | When It Breaks |
|------|----------|------------|----------------|
| Loading all collection items for every setup view | Slow page loads, high memory usage | Paginate collection views; setup views should query only member items | 500+ items in collection |
| Recomputing all setup totals on every item edit | Edit latency increases linearly with number of setups | Only recompute totals for setups containing the edited item | 20+ setups referencing overlapping items |
| Storing full-resolution photos without thumbnails | Page loads become unusably slow when browsing collection | Generate thumbnails on upload; use thumbnails in list views, full images only in detail view | 50+ items with photos |
| Loading all thread candidates for comparison | Irrelevant for small threads, but threads can accumulate many "considered" items | Limit comparison view to 3-4 selected candidates; archive dismissed ones | 15+ candidates in a single thread |
| 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) |
## Security Mistakes
@@ -173,9 +243,9 @@ Domain-specific security issues beyond general web security.
| Mistake | Risk | Prevention |
|---------|------|------------|
| No backup mechanism for SQLite database | Single file corruption = total data loss of entire collection | Implement automatic periodic backups (copy the .db file). Provide a manual "export all" button. Single-user apps have no server-side backup by default. |
| Product URLs stored without sanitization | Stored URLs could contain javascript: protocol or XSS payloads if rendered as links | Validate URLs on save (must be http/https). Render with `rel="noopener noreferrer"`. |
| Image uploads without size/type validation | Malicious or accidental upload of huge files or non-image files | Validate file type (accept only jpg/png/webp) and enforce max size (e.g., 5MB) on upload. |
| 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. |
## UX Pitfalls
@@ -183,25 +253,28 @@ Common user experience mistakes in this domain.
| Pitfall | User Impact | Better Approach |
|---------|-------------|-----------------|
| Requiring all fields to add an item | Users abandon data entry because they do not know the weight or price yet for items they already own | Only require name. Make weight, price, category, etc. optional. Users fill in details over time. |
| No bulk operations for collection management | Adding 30 existing items one-by-one is painful enough that users never finish initial setup | Provide CSV import for initial collection population. Consider a "quick add" mode with minimal fields. |
| Thread resolution is destructive | User resolves a thread and loses all the research notes and rejected candidates | Archive resolved threads, do not delete them. Users want to reference why they chose item X over Y months later. |
| Flat item list with no visual grouping | Collection becomes an unscannable wall of text at 50+ items | Group by tag/category in the default view. Provide sort options (weight, price, date added). Show item thumbnails in list view. |
| Weight displayed without context | "450g" means nothing without knowing if that is heavy or light for this category | Show weight relative to the lightest/heaviest item in the same category, or relative to the item being replaced |
| No "undo" for destructive actions | Accidental deletion of an item with detailed notes is unrecoverable | Soft-delete with a 30-day trash, or at minimum a confirmation dialog that names the item being deleted |
| 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. |
## "Looks Done But Isn't" Checklist
Things that appear complete but are missing critical pieces.
- [ ] **Item CRUD:** Often missing image cleanup on delete -- verify orphaned images are removed when items are deleted
- [ ] **Planning threads:** Often missing the "link to existing collection item being replaced" -- verify threads can reference what they are upgrading
- [ ] **Setup composition:** Often missing recomputation on item changes -- verify that editing an item's weight updates all setups containing it
- [ ] **CSV import:** Often missing unit detection/conversion -- verify that importing "5 oz" vs "142g" both result in correct canonical storage
- [ ] **Thread resolution:** Often missing setup propagation -- verify that resolving a thread and adding the winner to collection offers to update setups that contained the replaced item
- [ ] **Comparison view:** Often missing delta computation -- verify that the comparison shows differences between candidates, not just raw values side by side
- [ ] **Dashboard totals:** Often missing staleness handling -- verify dashboard stats reflect current data, not cached snapshots
- [ ] **Item deletion:** Often missing setup impact check -- verify the user is warned "This item is in 3 setups" before confirming deletion
- [ ] **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
## Recovery Strategies
@@ -209,12 +282,13 @@ When pitfalls occur despite prevention, how to recover.
| Pitfall | Recovery Cost | Recovery Steps |
|---------|---------------|----------------|
| Mixed units without conversion | MEDIUM | Add unit column to items table. Write a migration script that prompts user to confirm/correct units for existing items. Recompute all setup totals. |
| Rigid category hierarchy | HIGH | Migrate categories to tags (each leaf category becomes a tag). Update all item references. Redesign category UI to tag-based UI. |
| Thread state machine bugs | MEDIUM | Audit all threads for impossible states. Write a cleanup script. Add transition validation. Retest all state transitions. |
| Image path breakage | LOW-MEDIUM | Write a script that scans DB for broken image paths. Move images to canonical location. Update paths. Add fallback placeholder. |
| Stale setup totals | LOW | Drop cached total columns. Replace with computed queries. One-time migration, no data loss. |
| Currency as float | MEDIUM | Multiply all price values by 100, change column type to integer (cents). Rounding during conversion may lose sub-cent precision. |
| 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. |
## Pitfall-to-Phase Mapping
@@ -222,28 +296,27 @@ How roadmap phases should address these pitfalls.
| Pitfall | Prevention Phase | Verification |
|---------|------------------|--------------|
| Unit handling | Phase 1: Data model | Schema stores canonical grams + original unit. Conversion utility exists with tests. |
| Category rigidity | Phase 1: Data model | Items have a tags array/join table. No hierarchical category table exists. |
| Image storage | Phase 1: Core CRUD | Images stored in `data/images/` with relative paths. Thumbnails generated on upload. Cleanup on delete. |
| Currency precision | Phase 1: Data model | Price stored as integer cents. Display layer formats to dollars/euros. |
| Thread state machine | Phase 2: Planning threads | State transitions documented in code. Invalid transitions throw errors. Resolution is transactional. |
| Comparison usefulness | Phase 2: Planning threads | Comparison view shows deltas. Thread can link to "item being replaced." Setup impact visible. |
| Setup integrity | Phase 3: Setups | Totals computed from live data. Item deletion warns about setup membership. Soft-delete or archive for removed items. |
| Data loss / no backup | Phase 1: Infrastructure | Automatic DB backup on a schedule. Manual export button on dashboard. |
| Bulk import | Phase 1: Core CRUD | CSV import available from collection view. Handles unit variations in weight column. |
| 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. |
## Sources
- [Ultralight: The Gear Tracking App I'm Leaving LighterPack For](https://trailsmag.net/blogs/hiker-box/ultralight-the-gear-tracking-app-i-m-leaving-lighterpack-for) -- LighterPack limitations and community complaints
- [SQLite Internal vs External BLOBs](https://sqlite.org/intern-v-extern-blob.html) -- official SQLite guidance on image storage tradeoffs
- [35% Faster Than The Filesystem](https://sqlite.org/fasterthanfs.html) -- SQLite BLOB performance data
- [Comparison Tables for Products, Services, and Features - NN/g](https://www.nngroup.com/articles/comparison-tables/) -- comparison UX best practices
- [Designing The Perfect Feature Comparison Table - Smashing Magazine](https://www.smashingmagazine.com/2017/08/designing-perfect-feature-comparison-table/) -- comparison table design patterns
- [Comparing products: UX design best practices - Contentsquare](https://contentsquare.com/blog/comparing-products-design-practices-to-help-your-users-avoid-fragmented-comparison-7/) -- product comparison UX pitfalls
- [Common Unit Conversion Mistakes That Break Applications](https://helppdev.com/en/blog/common-unit-conversion-mistakes-that-break-applications) -- unit conversion antipatterns
- [Inventory App Design - UXPin](https://www.uxpin.com/studio/blog/inventory-app-design/) -- inventory app UX patterns
- [Designing better file organization around tags, not hierarchies](https://www.nayuki.io/page/designing-better-file-organization-around-tags-not-hierarchies) -- tags vs hierarchy tradeoffs
- [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
---
*Pitfalls research for: GearBox -- gear management and purchase planning app*
*Researched: 2026-03-14*
*Pitfalls research for: GearBox v1.2 -- Collection Power-Ups (search/filter, weight classification, charts, candidate status, unit selection)*
*Researched: 2026-03-16*